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

# Webhooks

> Receive signed events when an order is fulfilled or refunded, and verify them.

Register an endpoint to receive events as they happen instead of polling. SODACARDS
POSTs a signed JSON envelope to your URL; you verify the signature and act on it.

## Events

| Event             | When                                                     |
| ----------------- | -------------------------------------------------------- |
| `order.fulfilled` | An order was fulfilled and its codes are ready to fetch. |
| `order.refunded`  | An order was refunded.                                   |

The payload is customer-safe: it carries the order id and status, never a code, cost
or supplier. Fetch the codes from the authenticated endpoint once you receive
`order.fulfilled`.

```json theme={null}
{
  "id": "evt_1a2b3c",
  "type": "order.fulfilled",
  "created_at": "2026-07-25T09:52:00Z",
  "data": { "order_id": "ord_9x8y7z", "status": "fulfilled" }
}
```

## Registering an endpoint

`POST /v1/webhooks` with your HTTPS URL and the events you want. The response
returns a signing secret prefixed `whsec_`, shown **once** — store it now.

```bash theme={null}
curl https://api.sodacards.com/v1/webhooks \
  -H "X-API-Key: sc_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/sodacards", "events": ["order.fulfilled", "order.refunded"]}'
```

<Warning>
  Your endpoint must be HTTPS and publicly reachable. We refuse a URL that resolves
  to a private, loopback or link-local address.
</Warning>

## Verifying a delivery

Each request carries a `Sodacards-Signature` header:

```http theme={null}
Sodacards-Signature: t=1700000000,v1=38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789
Sodacards-Event-Id: evt_1a2b3c
Sodacards-Event-Type: order.fulfilled
```

`v1` is `HMAC-SHA256(secret, "<t>.<raw-request-body>")`, hex-encoded, keyed on your
`whsec_` secret. To verify:

1. Read `t` and `v1` from the header.
2. Reject the delivery if `t` is more than five minutes from now (replay defence).
3. Recompute the HMAC over the **exact raw body bytes** and compare it to `v1` in
   constant time.

During a secret rotation the header may carry several comma-separated `v1=` values;
accept the delivery if any one matches.

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
      timestamp = None
      signatures = []
      for part in header.split(","):
          key, _, value = part.partition("=")
          if key == "t":
              timestamp = int(value)
          elif key == "v1":
              signatures.append(value)
      if timestamp is None or abs(time.time() - timestamp) > tolerance:
          return False
      signed = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return any(hmac.compare_digest(expected, sig) for sig in signatures)
  ```

  ```javascript Node theme={null}
  import crypto from "node:crypto";

  export function verify(secret, header, rawBody, toleranceSec = 300) {
    let timestamp;
    const signatures = [];
    for (const part of header.split(",")) {
      const [key, value] = part.split("=");
      if (key === "t") timestamp = parseInt(value, 10);
      else if (key === "v1") signatures.push(value);
    }
    if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");
    return signatures.some(
      (sig) =>
        sig.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
    );
  }
  ```

  ```php PHP theme={null}
  <?php

  function verify(string $secret, string $header, string $rawBody, int $tolerance = 300): bool {
      $timestamp = null;
      $signatures = [];
      foreach (explode(',', $header) as $part) {
          [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
          if ($key === 't') {
              $timestamp = (int) $value;
          } elseif ($key === 'v1') {
              $signatures[] = $value;
          }
      }
      if ($timestamp === null || abs(time() - $timestamp) > $tolerance) {
          return false;
      }
      $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
      foreach ($signatures as $signature) {
          if (hash_equals($expected, $signature)) {
              return true;
          }
      }
      return false;
  }
  ```

  ```go Go theme={null}
  package sodacards

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"strconv"
  	"strings"
  	"time"
  )

  func Verify(secret, header string, rawBody []byte, tolerance time.Duration) bool {
  	var timestamp int64
  	var signatures []string
  	for _, part := range strings.Split(header, ",") {
  		key, value, ok := strings.Cut(part, "=")
  		if !ok {
  			continue
  		}
  		switch key {
  		case "t":
  			timestamp, _ = strconv.ParseInt(value, 10, 64)
  		case "v1":
  			signatures = append(signatures, value)
  		}
  	}
  	if timestamp == 0 {
  		return false
  	}
  	if delta := time.Since(time.Unix(timestamp, 0)); delta > tolerance || delta < -tolerance {
  		return false
  	}
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))
  	for _, signature := range signatures {
  		if hmac.Equal([]byte(signature), []byte(expected)) {
  			return true
  		}
  	}
  	return false
  }
  ```
</CodeGroup>

<Note>
  Verify over the **raw body**, before any JSON parsing re-serialises it. A
  re-encoded body will not match the signature.
</Note>

## Delivery, retries and de-duplication

Delivery is at-least-once, so the same event may arrive more than once: **de-duplicate
on `Sodacards-Event-Id`**. Return any `2xx` to acknowledge; a non-2xx, a timeout or a
connection error is retried on a backoff schedule. An endpoint that fails continuously
for several days is automatically disabled, and you are notified.

Respond quickly — acknowledge first, then do your work asynchronously — so a slow
handler does not look like a failed delivery.
