> ## Documentation Index
> Fetch the complete documentation index at: https://docs.matambaintelligence.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive order events at your HTTPS endpoint, check that each one came from us, and catch up on any you missed.

Each time an order changes state, or more of it fills, we create an event. We send the event to your webhook endpoint as an HTTPS `POST`, and you can also read it from `GET /v1/events` for 30 days. Events cover orders today.

The format follows [Standard Webhooks](https://www.standardwebhooks.com), so any Standard Webhooks library can verify our signatures.

## Register your endpoint

We register your endpoint for you. There's no API for it. Send us the URL, and we send you its signing secret. The URL must meet these rules:

| Rule                                  | Example                                                       |
| ------------------------------------- | ------------------------------------------------------------- |
| It uses `https`.                      | `https://hooks.example.com/matamba`                           |
| It has no username or password in it. | Not `https://user:pass@hooks.example.com/`                    |
| It has no query string.               | Not `https://hooks.example.com/matamba?token=abc`             |
| Its host is a public address.         | Not a private, loopback, link-local or other reserved address |

We check the address each time we connect, after the host name resolves. We don't use a proxy, and we don't follow redirects.

You receive the events created after we register your endpoint. Events from before then are in `GET /v1/events`. If you move to a new URL, we keep your secret, and events that are still due go to the new URL.

## What we send

Each event is a `POST` with a JSON body and these headers:

| Header              | Value                                                                                                                                        |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                                                                                                           |
| `User-Agent`        | `Matamba-Webhooks/1.0`                                                                                                                       |
| `webhook-id`        | The event's ID, such as `msg_5f0c2a9e7b1d4c3a8e6f0b12`. It's the same on every retry, so use it to ignore an event you've already processed. |
| `webhook-timestamp` | When we sent this attempt, in Unix seconds.                                                                                                  |
| `webhook-signature` | One or more signatures, separated by spaces, each written as `v1,` and a base64 value.                                                       |

The body, formatted here for reading:

```json Event body theme={"dark"}
{
  "type": "order.filled",
  "timestamp": "2026-09-25T06:13:02Z",
  "version": 2,
  "data": {
    "object": "order",
    "id": "ord_62jxryztxctob7uopekg",
    "state": "filled",
    "account": "1000000001",
    "symbol": "DANGCEM",
    "side": "buy",
    "quantity": 10,
    "type": "market",
    "time_in_force": "day",
    "price_kobo": null,
    "filled_quantity": 10,
    "exchange_order_id": "100001",
    "reason": null,
    "reject_reason": null,
    "created_at": "2026-09-25T06:12:34Z",
    "updated_at": "2026-09-25T06:13:02Z"
  }
}
```

| Field       | Description                                                                                                                                                                                                                                                                                |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type`      | `order.` followed by the order's new state: `order.pending`, `order.partially_filled`, `order.filled`, `order.rejected`, `order.cancelled`, `order.expired`, `order.uncertain` or `order.refused`. An order that fills in several parts sends `order.partially_filled` once for each part. |
| `timestamp` | The order's `updated_at`.                                                                                                                                                                                                                                                                  |
| `version`   | A number that increases with every event and is never reused. Events can arrive out of order, so use it to ignore an event older than one you've already applied.                                                                                                                          |
| `data`      | The order, exactly as `GET /v1/orders/{id}` returns it.                                                                                                                                                                                                                                    |

New event types can appear without notice. Ignore any type you don't handle. See [Versioning](/versioning).

## Verify each event

Check the signature before you trust an event. Anyone who learns your URL can send it a request.

<Steps>
  <Step title="Read the raw body">
    Use the body bytes exactly as they arrived. Parsing the JSON and writing it out again changes the bytes, and the signature won't match.
  </Step>

  <Step title="Check the timestamp">
    Reject the event if `webhook-timestamp` is more than 5 minutes before or after your server's clock. This is the default tolerance in the Standard Webhooks libraries, and it stops an old event from being replayed. We stamp each attempt when we send it, so a retry carries a fresh timestamp.
  </Step>

  <Step title="Build the signed content">
    Join the `webhook-id` header, the `webhook-timestamp` header and the raw body with a full stop between each: `id.timestamp.body`.
  </Step>

  <Step title="Compute the signature">
    Your secret starts with `whsec_`. Base64 decode the part after `whsec_`, and use the decoded bytes as the key. Don't use the secret text itself. Compute an HMAC-SHA256 of the signed content with that key, and base64 encode the result.
  </Step>

  <Step title="Compare">
    Split `webhook-signature` on spaces. Accept the event if any entry is `v1,` followed by your value. Compare with a constant-time function, not `==`.
  </Step>
</Steps>

<CodeGroup>
  ```javascript Node.js theme={"dark"}
  import crypto from "node:crypto";

  const TOLERANCE_SECONDS = 5 * 60;

  // headers: the request's headers, with lowercase names.
  // rawBody: a Buffer holding the body exactly as it arrived.
  export function verifyWebhook(secret, headers, rawBody) {
    const id = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatures = headers["webhook-signature"];
    if (!id || !timestamp || !signatures || !secret.startsWith("whsec_")) {
      return false;
    }

    const sent = Number(timestamp);
    const now = Math.floor(Date.now() / 1000);
    if (!Number.isInteger(sent) || Math.abs(now - sent) > TOLERANCE_SECONDS) {
      return false;
    }

    const key = Buffer.from(secret.slice("whsec_".length), "base64");
    const expected = crypto
      .createHmac("sha256", key)
      .update(`${id}.${timestamp}.`)
      .update(rawBody)
      .digest();

    return signatures.split(" ").some((entry) => {
      const [version, value] = entry.split(",");
      if (version !== "v1" || !value) {
        return false;
      }
      const given = Buffer.from(value, "base64");
      return given.length === expected.length && crypto.timingSafeEqual(given, expected);
    });
  }
  ```

  ```python Python theme={"dark"}
  import base64
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 5 * 60


  def verify_webhook(secret: str, headers, raw_body: bytes) -> bool:
      """headers: the request's headers. raw_body: the body exactly as it arrived."""
      msg_id = headers.get("webhook-id")
      timestamp = headers.get("webhook-timestamp")
      signatures = headers.get("webhook-signature")
      if not msg_id or not timestamp or not signatures or not secret.startswith("whsec_"):
          return False

      try:
          sent = int(timestamp)
      except ValueError:
          return False
      if abs(time.time() - sent) > TOLERANCE_SECONDS:
          return False

      key = base64.b64decode(secret[len("whsec_"):])
      signed = f"{msg_id}.{timestamp}.".encode() + raw_body
      expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

      for entry in signatures.split(" "):
          version, _, value = entry.partition(",")
          if version == "v1" and hmac.compare_digest(value, expected):
              return True
      return False
  ```

  ```go Go theme={"dark"}
  package webhooks

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/base64"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  const tolerance = 5 * time.Minute

  // Verify reports whether an event came from us. body is the request body
  // exactly as it arrived.
  func Verify(secret string, h http.Header, body []byte) bool {
  	id, ts, signatures := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
  	encoded, isSecret := strings.CutPrefix(secret, "whsec_")
  	if id == "" || signatures == "" || !isSecret {
  		return false
  	}

  	sent, err := strconv.ParseInt(ts, 10, 64)
  	if err != nil {
  		return false
  	}
  	if age := time.Since(time.Unix(sent, 0)); age > tolerance || age < -tolerance {
  		return false
  	}

  	key, err := base64.StdEncoding.DecodeString(encoded)
  	if err != nil {
  		return false
  	}
  	mac := hmac.New(sha256.New, key)
  	mac.Write([]byte(id + "." + ts + "."))
  	mac.Write(body)
  	expected := mac.Sum(nil)

  	for _, entry := range strings.Split(signatures, " ") {
  		version, value, _ := strings.Cut(entry, ",")
  		given, err := base64.StdEncoding.DecodeString(value)
  		if version == "v1" && err == nil && hmac.Equal(given, expected) {
  			return true
  		}
  	}
  	return false
  }
  ```
</CodeGroup>

### Test your verification

Run your code against this example before your first real event. The secret is made up for this page and signs nothing else. The body is on one line, exactly as we send it. Your code should accept it once you allow for the old timestamp.

```text Test vector theme={"dark"}
secret             whsec_bWF0YW1iYS1nYXRld2F5LWRvY3MtdGVzdC12ZWN0b3I=
webhook-id         msg_5f0c2a9e7b1d4c3a8e6f0b12
webhook-timestamp  1790316785
body               {"type":"order.filled","timestamp":"2026-09-25T06:13:02Z","version":2,"data":{"object":"order","id":"ord_62jxryztxctob7uopekg","state":"filled","account":"1000000001","symbol":"DANGCEM","side":"buy","quantity":10,"type":"market","time_in_force":"day","price_kobo":null,"filled_quantity":10,"exchange_order_id":"100001","reason":null,"reject_reason":null,"created_at":"2026-09-25T06:12:34Z","updated_at":"2026-09-25T06:13:02Z"}}
webhook-signature  v1,BKSLVITj1ZviEHXWR8LiHLeu4nsjPoQid4cb3xSz0Eo=
```

## Respond

Return any `2xx` status within 10 seconds. We read up to 64 KiB of your response and ignore what it says. Save the event and do the work after you respond, so a slow task doesn't turn into a failed delivery.

## Retries

We send each new event within about 5 seconds of recording the change. If an attempt fails, we try again on this schedule:

| After failed attempt | Next attempt     |
| -------------------- | ---------------- |
| 1                    | 1 minute later   |
| 2                    | 5 minutes later  |
| 3                    | 15 minutes later |
| 4                    | 30 minutes later |
| 5                    | 1 hour later     |
| 6                    | 2 hours later    |
| 7                    | 4 hours later    |
| 8                    | 8 hours later    |
| 9 and after          | 12 hours later   |

We stop sending an event 28 hours and 51 minutes after the change it describes. It stays in `GET /v1/events`.

| Your endpoint                                                                                    | What we do                                                                                                                                                          |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Returns `2xx`                                                                                    | Delivered.                                                                                                                                                          |
| Doesn't answer within 10 seconds, can't be reached, or returns a redirect, `408`, `429` or `5xx` | A failed attempt. After 3 of these in a row, we pause your endpoint and try one event about once a minute. When one succeeds, we send what's waiting, oldest first. |
| Returns any other `4xx`                                                                          | This event failed, and we retry it on the schedule above. We keep sending your other events.                                                                        |

Delivery is at least once. You can receive the same event twice, and a retried event can arrive after a newer one. Use `webhook-id` to ignore duplicates and `version` to ignore stale changes.

## Catch up on missed events

`GET /v1/events` lists every event for 30 days, oldest first, whether or not your endpoint accepted it. Use it after downtime, or instead of a webhook endpoint.

1. Call `GET /v1/events` without a cursor. Pass `limit` to set the page size, from 1 to 100. The default is 25.
2. Process the events, then store the `next_cursor` from the response.
3. Next time, pass that value as `cursor`. When there are no new events, you get an empty page and the same cursor back, so keep it for next time.

## Rotate your secret

We rotate your secret when you ask. For 24 hours after a rotation, we sign every event with both the new and the old secret, so `webhook-signature` carries two entries, the new one first. Switch your server to the new secret within those 24 hours. We can start another rotation only after the 24 hours end.
