# Webhook

The Webhook feature allows you to receive real-time HTTP POST notifications whenever trading events occur on your MetaCopier accounts. This enables you to build custom integrations, dashboards, alerting systems, or automated workflows.

## Overview

When enabled, MetaCopier sends a JSON payload to your HTTPS endpoint whenever one of the subscribed events fires. You can configure authentication, delivery behavior, and payload content.

### Supported Events

| Event             | Event type string | Description                              |
| ----------------- | ----------------- | ---------------------------------------- |
| `POSITION_OPENED` | `position.opened` | A new position was opened on the account |
| `POSITION_CLOSED` | `position.closed` | A position was closed on the account     |
| `HISTORY_UPDATED` | `history.updated` | The trade history was updated            |

## Setup

### Account Level

1. Navigate to your **Account → Features**
2. Click the **+** button and select **Webhook**
3. Configure the webhook settings and save

### Project Level

1. Navigate to your **Project → Webhooks** panel in the sidebar
2. Fill in the configuration form and save

{% hint style="info" %}
At the project level, the webhook applies to **all accounts** in that project. Only one webhook can be configured per project.
{% endhint %}

## Configuration

### General

| Setting            | Description                                             |
| ------------------ | ------------------------------------------------------- |
| **Enable webhook** | Toggle delivery on/off without losing the configuration |
| **Endpoint URL**   | Your HTTPS endpoint (must start with `https://`)        |
| **Description**    | Optional label for your reference                       |

### Authentication

| Method           | Description                                                                           |
| ---------------- | ------------------------------------------------------------------------------------- |
| **None**         | No authentication header is sent                                                      |
| **HMAC-SHA256**  | A signature is computed over the payload and sent in the `X-Webhook-Signature` header |
| **Bearer Token** | Your token is sent as `Authorization: Bearer <token>`                                 |

#### HMAC-SHA256 Details

When HMAC-SHA256 is selected:

* The signature is computed as `HMAC-SHA256(secret, payload_body)` and sent hex-encoded in the `X-MetaCopier-Signature` header.
* If **Include timestamp in signature** is enabled, a `X-MetaCopier-Timestamp` header is sent (value in milliseconds) and the signature is computed over `timestamp.payload_body` for replay protection.
* The secret must be at least 32 characters. You can use the **Generate** button to create a cryptographically secure key.

### Delivery Configuration

| Setting              | Description                                                            | Range     | Default |
| -------------------- | ---------------------------------------------------------------------- | --------- | ------- |
| **Max retries**      | Number of retry attempts on failure                                    | 0–5       | 3       |
| **Retry delay (ms)** | Initial delay between retries (exponential backoff: delay × 2^attempt) | 500–30000 | 2000    |
| **Timeout (sec)**    | HTTP request timeout                                                   | 5–60      | 30      |
| **Rate limit/min**   | Maximum deliveries per minute (0 = unlimited)                          | 0–1000    | 60      |

### Payload Options

| Option                       | Description                                           | Default |
| ---------------------------- | ----------------------------------------------------- | ------- |
| **Include position details** | Include symbol, volume, prices, profit in the payload | true    |
| **Include account metadata** | Include broker name and login number                  | false   |

### Custom Headers

You can add up to 10 custom HTTP headers that will be included with every webhook request. This is useful for routing, API keys in downstream systems, or correlation IDs.

{% hint style="warning" %}
Reserved header names are blocked: `Content-Type`, `Authorization`, and any header starting with `X-MetaCopier-`.
{% endhint %}

## Payload Format

All webhook payloads are sent as `Content-Type: application/json` with a POST request. Fields with `null` values are excluded from the JSON.

### Example: Position Opened

```json
{
  "eventType": "position.opened",
  "timestamp": "2026-05-01T12:00:00.000Z",
  "accountId": "550e8400-e29b-41d4-a716-446655440000",
  "position": {
    "id": "100001",
    "symbol": "EURUSD",
    "orderType": "Buy",
    "volume": 0.10,
    "openPrice": 1.08542,
    "stopLoss": 1.08200,
    "takeProfit": 1.09000,
    "openTime": "2026-05-01T11:59:58.000Z",
    "comment": "metacopier",
    "magicNumber": "123456"
  },
  "accountMetadata": {
    "broker": "ICMarkets-MT5",
    "login": "12345678"
  }
}
```

### Example: Position Closed

```json
{
  "eventType": "position.closed",
  "timestamp": "2026-05-01T14:30:00.000Z",
  "accountId": "550e8400-e29b-41d4-a716-446655440000",
  "position": {
    "id": "100001",
    "symbol": "EURUSD",
    "orderType": "Buy",
    "volume": 0.10,
    "openPrice": 1.08542,
    "closePrice": 1.08890,
    "stopLoss": 1.08200,
    "takeProfit": 1.09000,
    "openTime": "2026-05-01T11:59:58.000Z",
    "closeTime": "2026-05-01T14:29:55.000Z",
    "profit": 34.80,
    "netProfit": 33.50,
    "swap": -0.30,
    "commission": -1.00,
    "comment": "metacopier",
    "magicNumber": "123456"
  },
  "accountMetadata": {
    "broker": "ICMarkets-MT5",
    "login": "12345678"
  }
}
```

### Example: History Updated

```json
{
  "eventType": "history.updated",
  "timestamp": "2026-05-01T14:35:20.789Z",
  "accountId": "550e8400-e29b-41d4-a716-446655440000",
  "accountMetadata": {
    "broker": "ICMarkets-MT5",
    "login": "12345678"
  }
}
```

{% hint style="info" %}
When **Include position details** is disabled, the `position` field is omitted entirely.

When **Include account metadata** is disabled, the `accountMetadata` field is omitted.
{% endhint %}

### Position Fields Reference

| Field         | Type   | Description                                     |
| ------------- | ------ | ----------------------------------------------- |
| `id`          | string | Position ticket ID                              |
| `symbol`      | string | Trading symbol (e.g. `EURUSD`)                  |
| `orderType`   | string | `Buy`, `Sell`, `BuyLimit`, `SellLimit`, etc.    |
| `volume`      | number | Lot size                                        |
| `openPrice`   | number | Entry price                                     |
| `closePrice`  | number | Exit price (only on close events)               |
| `stopLoss`    | number | Stop loss price                                 |
| `takeProfit`  | number | Take profit price                               |
| `openTime`    | string | ISO 8601 open timestamp                         |
| `closeTime`   | string | ISO 8601 close timestamp (only on close events) |
| `profit`      | number | Gross profit                                    |
| `netProfit`   | number | Profit after swap and commission                |
| `swap`        | number | Swap/rollover charges                           |
| `commission`  | number | Trading commission                              |
| `comment`     | string | Position comment                                |
| `magicNumber` | string | Magic number identifier                         |

## Headers Sent

Every webhook request includes these headers:

| Header                          | Description                                                              |
| ------------------------------- | ------------------------------------------------------------------------ |
| `Content-Type`                  | Always `application/json`                                                |
| `X-MetaCopier-Delivery-Attempt` | The delivery attempt number (starts at 1)                                |
| `X-MetaCopier-Timestamp`        | Unix timestamp in **milliseconds** (when HMAC with timestamp is enabled) |
| `X-MetaCopier-Signature`        | HMAC-SHA256 hex signature (when HMAC auth is configured)                 |
| `Authorization`                 | `Bearer <token>` (when Bearer Token auth is configured)                  |
| Custom headers                  | Any custom headers you configured                                        |

## Client-Side Implementation

Below are examples showing how to receive and verify webhook requests on your server.

***

### C\#

```csharp
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/webhooks/metacopier")]
public class MetaCopierWebhookController : ControllerBase
{
    private const string HmacSecret = "your-hmac-secret-here-min-32-chars!!";
    private const int MaxTimestampAgeSeconds = 300; // 5 minutes

    [HttpPost]
    public IActionResult HandleWebhook()
    {
        using var reader = new StreamReader(Request.Body);
        var payload = reader.ReadToEndAsync().Result;

        // Verify HMAC signature
        if (!VerifySignature(payload))
            return Unauthorized("Invalid signature");

        // Parse the event
        var json = System.Text.Json.JsonDocument.Parse(payload);
        var eventType = json.RootElement.GetProperty("eventType").GetString();

        switch (eventType)
        {
            case "position.opened":
                HandlePositionOpened(json.RootElement);
                break;
            case "position.closed":
                HandlePositionClosed(json.RootElement);
                break;
            case "history.updated":
                HandleHistoryUpdated(json.RootElement);
                break;
        }

        return Ok();
    }

    private bool VerifySignature(string payload)
    {
        var signatureHeader = Request.Headers["X-MetaCopier-Signature"].FirstOrDefault();
        if (string.IsNullOrEmpty(signatureHeader))
            return false;

        var timestampHeader = Request.Headers["X-MetaCopier-Timestamp"].FirstOrDefault();
        var signedPayload = string.IsNullOrEmpty(timestampHeader)
            ? payload
            : $"{timestampHeader}.{payload}";

        // Check timestamp freshness (replay protection) - timestamp is in milliseconds
        if (!string.IsNullOrEmpty(timestampHeader))
        {
            if (long.TryParse(timestampHeader, out var timestampMs))
            {
                var ageMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestampMs;
                if (Math.Abs(ageMs) > MaxTimestampAgeSeconds * 1000L)
                    return false;
            }
        }

        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(HmacSecret));
        var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
        var computedSignature = BitConverter.ToString(computedHash).Replace("-", "").ToLowerInvariant();

        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(computedSignature),
            Encoding.UTF8.GetBytes(signatureHeader));
    }

    private void HandlePositionOpened(System.Text.Json.JsonElement root)
    {
        var position = root.GetProperty("position");
        var id = position.GetProperty("id").GetString();
        var symbol = position.GetProperty("symbol").GetString();
        Console.WriteLine($"Position opened: {symbol} id={id}");
    }

    private void HandlePositionClosed(System.Text.Json.JsonElement root)
    {
        var position = root.GetProperty("position");
        var id = position.GetProperty("id").GetString();
        var profit = position.GetProperty("profit").GetDouble();
        Console.WriteLine($"Position closed: id={id} profit={profit}");
    }

    private void HandleHistoryUpdated(System.Text.Json.JsonElement root)
    {
        var accountId = root.GetProperty("accountId").GetString();
        Console.WriteLine($"History updated for account {accountId}");
    }
}
```

***

### Java

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

@RestController
@RequestMapping("/api/webhooks/metacopier")
public class MetaCopierWebhookController {

    private static final String HMAC_SECRET = "your-hmac-secret-here-min-32-chars!!";
    private static final long MAX_TIMESTAMP_AGE_SECONDS = 300; // 5 minutes
    private final ObjectMapper objectMapper = new ObjectMapper();

    @PostMapping
    public ResponseEntity<String> handleWebhook(
            @RequestBody String payload,
            @RequestHeader(value = "X-MetaCopier-Signature", required = false) String signature,
            @RequestHeader(value = "X-MetaCopier-Timestamp", required = false) String timestamp) {

        // Verify HMAC signature
        if (!verifySignature(payload, signature, timestamp)) {
            return ResponseEntity.status(401).body("Invalid signature");
        }

        try {
            JsonNode json = objectMapper.readTree(payload);
            String eventType = json.get("eventType").asText();

            switch (eventType) {
                case "position.opened":
                    handlePositionOpened(json);
                    break;
                case "position.closed":
                    handlePositionClosed(json);
                    break;
                case "history.updated":
                    handleHistoryUpdated(json);
                    break;
            }
        } catch (Exception e) {
            return ResponseEntity.badRequest().body("Invalid payload");
        }

        return ResponseEntity.ok("OK");
    }

    private boolean verifySignature(String payload, String signature, String timestamp) {
        if (signature == null || signature.isEmpty()) {
            return false;
        }

        // Check timestamp freshness - timestamp is in milliseconds
        if (timestamp != null && !timestamp.isEmpty()) {
            long tsMs = Long.parseLong(timestamp);
            long ageMs = Math.abs(System.currentTimeMillis() - tsMs);
            if (ageMs > MAX_TIMESTAMP_AGE_SECONDS * 1000L) {
                return false;
            }
        }

        String signedPayload = (timestamp != null && !timestamp.isEmpty())
                ? timestamp + "." + payload
                : payload;

        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(
                    HMAC_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(secretKey);
            byte[] hash = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
            String computedSignature = bytesToHex(hash);
            return MessageDigest.isEqual(
                    computedSignature.getBytes(StandardCharsets.UTF_8),
                    signature.getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            return false;
        }
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

    private void handlePositionOpened(JsonNode json) {
        JsonNode position = json.get("position");
        String id = position.get("id").asText();
        String symbol = position.get("symbol").asText();
        System.out.println("Position opened: " + symbol + " id=" + id);
    }

    private void handlePositionClosed(JsonNode json) {
        JsonNode position = json.get("position");
        String id = position.get("id").asText();
        double profit = position.get("profit").asDouble();
        System.out.println("Position closed: id=" + id + " profit=" + profit);
    }

    private void handleHistoryUpdated(JsonNode json) {
        String accountId = json.get("accountId").asText();
        System.out.println("History updated for account " + accountId);
    }
}
```

***

### TypeScript

```typescript
import express from "express";
import crypto from "crypto";

const app = express();
const HMAC_SECRET = "your-hmac-secret-here-min-32-chars!!";
const MAX_TIMESTAMP_AGE_SECONDS = 300; // 5 minutes

app.post(
  "/api/webhooks/metacopier",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const payload = req.body.toString("utf-8");
    const signature = req.headers["x-metacopier-signature"] as string;
    const timestamp = req.headers["x-metacopier-timestamp"] as string;

    // Verify HMAC signature
    if (!verifySignature(payload, signature, timestamp)) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(payload);

    switch (event.eventType) {
      case "position.opened":
        console.log(
          `Position opened: ${event.position.symbol} id=${event.position.id}`
        );
        break;
      case "position.closed":
        console.log(
          `Position closed: id=${event.position.id} profit=${event.position.profit}`
        );
        break;
      case "history.updated":
        console.log(`History updated for account ${event.accountId}`);
        break;
    }

    res.status(200).send("OK");
  }
);

function verifySignature(
  payload: string,
  signature: string | undefined,
  timestamp: string | undefined
): boolean {
  if (!signature) return false;

  // Check timestamp freshness - timestamp is in milliseconds
  if (timestamp) {
    const ageMs = Math.abs(Date.now() - parseInt(timestamp));
    if (ageMs > MAX_TIMESTAMP_AGE_SECONDS * 1000) return false;
  }

  const signedPayload = timestamp ? `${timestamp}.${payload}` : payload;
  const computedSignature = crypto
    .createHmac("sha256", HMAC_SECRET)
    .update(signedPayload)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(computedSignature),
    Buffer.from(signature)
  );
}

app.listen(3000, () => console.log("Webhook server listening on port 3000"));
```

***

### Python

```python
import hmac
import hashlib
import time
import json
from flask import Flask, request

app = Flask(__name__)

HMAC_SECRET = "your-hmac-secret-here-min-32-chars!!"
MAX_TIMESTAMP_AGE_SECONDS = 300  # 5 minutes


@app.route("/api/webhooks/metacopier", methods=["POST"])
def handle_webhook():
    payload = request.get_data(as_text=True)
    signature = request.headers.get("X-MetaCopier-Signature")
    timestamp = request.headers.get("X-MetaCopier-Timestamp")

    # Verify HMAC signature
    if not verify_signature(payload, signature, timestamp):
        return "Invalid signature", 401

    event = json.loads(payload)
    event_type = event.get("eventType")

    if event_type == "position.opened":
        position = event["position"]
        print(f"Position opened: {position['symbol']} id={position['id']}")
    elif event_type == "position.closed":
        position = event["position"]
        print(f"Position closed: id={position['id']} profit={position['profit']}")
    elif event_type == "history.updated":
        print(f"History updated for account {event['accountId']}")

    return "OK", 200


def verify_signature(payload: str, signature: str, timestamp: str) -> bool:
    if not signature:
        return False

    # Check timestamp freshness - timestamp is in milliseconds
    if timestamp:
        age_ms = abs(int(time.time() * 1000) - int(timestamp))
        if age_ms > MAX_TIMESTAMP_AGE_SECONDS * 1000:
            return False

    signed_payload = f"{timestamp}.{payload}" if timestamp else payload
    computed = hmac.new(
        HMAC_SECRET.encode("utf-8"),
        signed_payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(computed, signature)


if __name__ == "__main__":
    app.run(port=3000)
```

***

### Go

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"net/http"
	"strconv"
	"time"
)

const (
	hmacSecret             = "your-hmac-secret-here-min-32-chars!!"
	maxTimestampAgeSeconds = 300 // 5 minutes
)

type WebhookPayload struct {
	EventType       string          `json:"eventType"`
	Timestamp       string          `json:"timestamp"`
	AccountID       string          `json:"accountId"`
	Position        *Position       `json:"position,omitempty"`
	AccountMetadata *AccountMetadata `json:"accountMetadata,omitempty"`
}

type Position struct {
	ID         string  `json:"id"`
	Symbol     string  `json:"symbol"`
	OrderType  string  `json:"orderType"`
	Volume     float64 `json:"volume"`
	OpenPrice  float64 `json:"openPrice"`
	ClosePrice float64 `json:"closePrice"`
	StopLoss   float64 `json:"stopLoss"`
	TakeProfit float64 `json:"takeProfit"`
	Profit     float64 `json:"profit"`
	NetProfit  float64 `json:"netProfit"`
	Swap       float64 `json:"swap"`
	Commission float64 `json:"commission"`
}

type AccountMetadata struct {
	Broker string `json:"broker"`
	Login  string `json:"login"`
}

func main() {
	http.HandleFunc("/api/webhooks/metacopier", handleWebhook)
	fmt.Println("Webhook server listening on port 3000")
	http.ListenAndServe(":3000", nil)
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read body", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	signature := r.Header.Get("X-MetaCopier-Signature")
	timestamp := r.Header.Get("X-MetaCopier-Timestamp")

	// Verify HMAC signature
	if !verifySignature(string(body), signature, timestamp) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	var payload WebhookPayload
	if err := json.Unmarshal(body, &payload); err != nil {
		http.Error(w, "Invalid payload", http.StatusBadRequest)
		return
	}

	switch payload.EventType {
	case "position.opened":
		fmt.Printf("Position opened: %s id=%s\n", payload.Position.Symbol, payload.Position.ID)
	case "position.closed":
		fmt.Printf("Position closed: id=%s profit=%.2f\n", payload.Position.ID, payload.Position.Profit)
	case "history.updated":
		fmt.Printf("History updated for account %s\n", payload.AccountID)
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte("OK"))
}

func verifySignature(payload, signature, timestamp string) bool {
	if signature == "" {
		return false
	}

	// Check timestamp freshness - timestamp is in milliseconds
	if timestamp != "" {
		ts, err := strconv.ParseInt(timestamp, 10, 64)
		if err != nil {
			return false
		}
		ageMs := math.Abs(float64(time.Now().UnixMilli() - ts))
		if ageMs > maxTimestampAgeSeconds*1000 {
			return false
		}
	}

	signedPayload := payload
	if timestamp != "" {
		signedPayload = timestamp + "." + payload
	}

	mac := hmac.New(sha256.New, []byte(hmacSecret))
	mac.Write([]byte(signedPayload))
	computed := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(computed), []byte(signature))
}
```

***

## Best Practices

1. **Always verify signatures** - Never trust incoming webhooks without validating the HMAC signature.
2. **Check timestamp freshness** - Reject requests with timestamps older than 5 minutes to prevent replay attacks.
3. **Respond quickly** - Return a `200 OK` as soon as possible. Process the event asynchronously if your logic is slow.
4. **Handle retries idempotently** - The same event may be delivered multiple times. Use the position `id` and `timestamp` to deduplicate.
5. **Use HTTPS** - MetaCopier only delivers webhooks to `https://` endpoints.
6. **Monitor failures** - If your endpoint returns errors consistently, check your server logs and ensure the endpoint is accessible.

## Troubleshooting

| Issue                        | Solution                                                                |
| ---------------------------- | ----------------------------------------------------------------------- |
| Not receiving webhooks       | Ensure the webhook is **enabled** and your endpoint returns `200`       |
| Signature verification fails | Check that your secret matches exactly and you're using UTF-8 encoding  |
| Stale timestamp errors       | Ensure your server clock is synchronized (NTP)                          |
| Rate limited                 | Increase the **Rate limit/min** setting or optimize your endpoint speed |


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.metacopier.io/tutorials/webhook.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
