> For the complete documentation index, see [llms.txt](https://docs.rozo.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.rozo.ai/integration/api-doc/merchant-api/webhook.md).

# Webhook

This guide is for partners who registered on **partners.rozo.ai** and want to receive payment notifications in their own backend.

You configure your webhook URL **once** in the dashboard. From then on, every payment created under your `appId` automatically uses that URL and the long-lived HMAC secret you got at registration — you never pass `webhookUrl` or `webhookSecret` on individual payment requests.

***

## 1. Concepts

| Term           | Meaning                                                                                                                                          |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `appId`        | Your account identifier. Derived from your slug at registration: `merchant_<slug>` or `wallet_<slug>`                                            |
| Webhook URL    | The HTTPS endpoint Rozo POSTs payment events to                                                                                                  |
| Webhook secret | A 64-character hex string used to sign every webhook to your URL. Generated by Rozo at registration, rotatable from the dashboard                |
| Fingerprint    | `sha256(secret)` (hex). Shown in the dashboard so you can confirm which secret your server is currently using **without** exposing the raw value |

The raw secret is shown to you **once** — at registration and again when you rotate. After that, only the fingerprint is visible.

***

## 2. Get Your Webhook Secret

### At registration

When you complete registration on partners.rozo.ai, the response includes:

```json
{
  "developerId": "...",
  "appId": "merchant_hellocafe",
  "webhookUrl": "https://hellocafe.com/rozo/webhook",
  "webhookSecret": "a1b2c3d4...64-hex-chars",
  "webhookSecretFingerprint": "sha256:abcd1234...full-hex"
}
```

**Copy the `webhookSecret` immediately.** It is not shown again.

### If you lost it — rotate

1. Sign in to [partners.rozo.ai](https://partners.rozo.ai).
2. Open **Settings → Webhooks**.
3. Click **Rotate secret** and confirm.
4. Copy the new secret. The old one stops working **immediately** — there is no grace period.

The dashboard always shows the fingerprint of the **active** secret and the timestamp of the last rotation, so you can confirm at a glance which version your server is configured for.

***

## 3. Set or Update Your Webhook URL

Either at registration (the `webhookUrl` field on `POST /merchant-api/register`) or later from **Settings → Webhooks**.

Requirements:

* Must start with `https://` (HTTP is rejected).
* Must be reachable from the public internet.
* Should return a `2xx` response **quickly**. If your processing is slow, return `200` immediately and process asynchronously.

You can change the URL at any time without rotating the secret. The change applies to **new** payment events; in-flight events for already-completed payments are not re-delivered, and pre-existing payments retain whatever URL was active when they were created.

***

## 4. What Triggers a Webhook

Rozo sends two webhook events per payment, both with the envelope described in §5:

| Event `type`               | Trigger                                                         |
| -------------------------- | --------------------------------------------------------------- |
| `payment_payin_completed`  | Buyer's payin tx confirmed on the source chain                  |
| `payment_payout_completed` | Payout tx confirmed on the destination chain (terminal success) |

Failure terminals (`payment_bounced` / `payment_expired` / `payment_refunded`) and intermediate states (`payment_started`, `payment_bridging`, `payment_payout_started`) do **not** generate a webhook. Your server must poll `GET /payment-api/payments/{id}` to observe those.

> **Failure handling is on you.** Schedule a periodic `GET /payment-api/payments/{id}` for any payment that hasn't reached a success terminal within your expected window — see §7 Delivery Semantics.

***

## 5. Request Format

Rozo sends a `POST` request with these headers:

```
Content-Type: application/json
X-Rozo-Timestamp: 1746282100000
X-Rozo-Signature: sha256=a1b2c3d4...
```

| Header             | Description                                                     |
| ------------------ | --------------------------------------------------------------- |
| `X-Rozo-Timestamp` | Unix timestamp in **milliseconds** when Rozo signed the request |
| `X-Rozo-Signature` | `sha256=` followed by the HMAC-SHA256 hex signature             |

### Body

The body is a small envelope wrapped around the same Payment object you get from `GET /payment-api/payments/{id}`. Reconciliation against your stored order is a key-by-key compare on `data`.

```json
{
  "event_id": "9d4f2e0c-7a55-4b1b-8e2a-6c1f0a5d8e30",
  "type": "payment_payout_completed",
  "timestamp": "2026-05-08T12:34:32.501Z",
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "appId": "merchant_hellocafe",
    "orderId": "order-2026-1042",
    "status": "payment_payout_completed",
    "type": "exactIn",
    "createdAt": "2026-05-08T12:30:00.000Z",
    "updatedAt": "2026-05-08T12:34:32.000Z",
    "expiresAt": "2026-05-08T13:00:00.000Z",
    "display": {
      "title": "Order #1042",
      "description": "Coffee + pastry",
      "currency": "USD"
    },
    "source": {
      "chainId": "8453",
      "tokenSymbol": "USDC",
      "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      "amount": "10.00",
      "receiverAddress": "0xForwarderAddressOnBase...",
      "fee": "0.05",
      "senderAddress": "0x1234567890123456789012345678901234567890",
      "txHash": "0xabcd1234...",
      "amountReceived": "10.00",
      "confirmedAt": "2026-05-08T12:31:55.000Z"
    },
    "destination": {
      "chainId": "1500",
      "receiverAddress": "GMERCHANTSTELLARWALLET...",
      "tokenSymbol": "USDC",
      "tokenAddress": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
      "amount": "9.95",
      "txHash": "3YgVxWDRKtWnzKNnXR6...",
      "confirmedAt": "2026-05-08T12:34:30.000Z"
    }
  }
}
```

#### Envelope fields

| Field       | Notes                                                                                                                                                                           |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_id`  | Globally unique delivery id. Use this as your **idempotency key**. Equal to the `deliveryId` shown in the dashboard's delivery log — paste it there to find the matching record |
| `type`      | `payment_payin_completed` or `payment_payout_completed` (see §4)                                                                                                                |
| `timestamp` | ISO-8601 (UTC) when Rozo emitted this event                                                                                                                                     |
| `data`      | The Payment object, identical to `GET /payment-api/payments/{data.id}` (with the fields below stripped)                                                                         |

#### Fields stripped from `data`

These are **omitted** from webhook payloads even though they appear on `GET /payment-api/payments/{id}`:

* `metadata` — internal channel only.
* `webhookUrl`, `webhookSecretSource` — self-referential.
* `paymentLink` — you generated this flow; you don't need the link.
* `merchant` — branding fields you already configured.

If you need any of the above, fetch them via `GET /payment-api/payments/{data.id}`.

#### Field presence by `type`

`data` always carries the full Payment schema — fields that haven't been populated yet are sent as `null`, never omitted. This matches the shape `GET /payment-api/payments/{id}` returns, so reconcile logic can be byte-for-byte identical.

| Field                          | `payment_payin_completed` | `payment_payout_completed` |
| ------------------------------ | ------------------------- | -------------------------- |
| `data.source.txHash`           | populated                 | populated                  |
| `data.source.senderAddress`    | populated                 | populated                  |
| `data.source.amountReceived`   | populated                 | populated                  |
| `data.source.confirmedAt`      | populated                 | populated                  |
| `data.destination.txHash`      | `null`                    | populated                  |
| `data.destination.confirmedAt` | `null`                    | populated                  |

### Concrete payload examples

`payment_payin_completed` for a `wallet_*` account (destination tx not yet known):

```json
{
  "event_id": "f1a8c0e2-2d36-4b87-9b2f-0a7c3e91d24e",
  "type": "payment_payin_completed",
  "timestamp": "2026-05-10T14:44:55.075Z",
  "data": {
    "id": "a22a0213-9b4e-4113-adef-acdf958a84ae",
    "appId": "wallet_hellotest",
    "orderId": null,
    "status": "payment_payin_completed",
    "type": "exactIn",
    "createdAt": "2026-05-10T14:42:00.000Z",
    "updatedAt": "2026-05-10T14:44:55.000Z",
    "display": {
      "title": "Test payment",
      "currency": "USD"
    },
    "source": {
      "chainId": "8453",
      "tokenSymbol": "USDC",
      "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      "amount": "10.00",
      "receiverAddress": "0xForwarderAddressOnBase...",
      "fee": "0.05",
      "senderAddress": "0x5772fbe7a7817ef7f586215ca8b23b8dd22c8897",
      "txHash": "0x76d360eb2ef590390e36533455da2db9f19cb2463275d93fa791cd6a3e965e5b",
      "amountReceived": "10.00",
      "confirmedAt": "2026-05-10T14:44:50.000Z"
    },
    "destination": {
      "chainId": "1500",
      "receiverAddress": "GUSERSTELLARWALLET...",
      "receiverMemo": null,
      "tokenSymbol": "USDC",
      "tokenAddress": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
      "amount": "9.95",
      "txHash": null,
      "confirmedAt": null
    }
  }
}
```

`payment_payout_completed` for the same payment (now with `destination.txHash` and `destination.confirmedAt` populated):

```json
{
  "event_id": "9d4f2e0c-7a55-4b1b-8e2a-6c1f0a5d8e30",
  "type": "payment_payout_completed",
  "timestamp": "2026-05-10T14:45:09.501Z",
  "data": {
    "id": "a22a0213-9b4e-4113-adef-acdf958a84ae",
    "appId": "wallet_hellotest",
    "orderId": null,
    "status": "payment_payout_completed",
    "type": "exactIn",
    "createdAt": "2026-05-10T14:42:00.000Z",
    "updatedAt": "2026-05-10T14:45:09.000Z",
    "display": {
      "title": "Test payment",
      "currency": "USD"
    },
    "source": {
      "chainId": "8453",
      "tokenSymbol": "USDC",
      "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      "amount": "10.00",
      "receiverAddress": "0xForwarderAddressOnBase...",
      "fee": "0.05",
      "senderAddress": "0x5772fbe7a7817ef7f586215ca8b23b8dd22c8897",
      "txHash": "0x76d360eb2ef590390e36533455da2db9f19cb2463275d93fa791cd6a3e965e5b",
      "amountReceived": "10.00",
      "confirmedAt": "2026-05-10T14:44:50.000Z"
    },
    "destination": {
      "chainId": "1500",
      "receiverAddress": "GUSERSTELLARWALLET...",
      "tokenSymbol": "USDC",
      "tokenAddress": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
      "amount": "9.95",
      "txHash": "3YgVxWDRKtWnzKNnXR6pqghBSTCBA8axpPHjApmyNZTvhqUShohfDRpv7FaEGjh48WyrEfxrGxZxJ6E2mNJQtV8u",
      "confirmedAt": "2026-05-10T14:45:08.000Z"
    }
  }
}
```

***

## 6. Verify the Signature

**Algorithm:** HMAC-SHA256\
**Message:** `${X-Rozo-Timestamp}.${raw request body}`\
**Header:** `X-Rozo-Signature: sha256=<hex>`

Always:

1. Confirm the timestamp is within **5 minutes** of your server clock (replay protection).
2. Compute the HMAC over `timestamp + "." + raw_body` using your webhook secret.
3. Compare with **constant-time** equality. Never use `===` on signature strings.
4. Use the **raw body bytes** — do not parse and re-serialize the JSON before signing.

### Node.js / TypeScript

```typescript
import crypto from 'crypto';
import express from 'express';

const app = express();

app.post(
  '/webhooks/rozo',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const timestamp = req.headers['x-rozo-timestamp'] as string;
    const sigHeader = req.headers['x-rozo-signature'] as string;
    const rawBody = req.body.toString('utf8');
    const secret = process.env.ROZO_WEBHOOK_SECRET!;

    // 1. Replay window
    if (Math.abs(Date.now() - parseInt(timestamp, 10)) > 5 * 60 * 1000) {
      return res.status(401).json({ error: 'timestamp_expired' });
    }

    // 2. Recompute signature
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    // 3. Constant-time compare. The header may be "sha256=<hex>" or "<hex>".
    const received = sigHeader.replace(/^sha256=/, '');
    const ok = crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(received, 'hex'),
    );
    if (!ok) return res.status(401).json({ error: 'invalid_signature' });

    // 4. Idempotency: event_id is globally unique per delivery.
    const event = JSON.parse(rawBody);
    if (await db.alreadyProcessed(event.event_id)) {
      return res.json({ ok: true });
    }

    // 5. Business logic. event.data is the same Payment object as
    //    GET /payment-api/payments/{id}.
    if (event.type === 'payment_payout_completed') {
      await fulfillOrder(event.data.id, event.data.destination.txHash);
    }

    await db.markProcessed(event.event_id);
    res.json({ ok: true });
  },
);
```

### Python (Flask)

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

app = Flask(__name__)
SECRET = os.environ['ROZO_WEBHOOK_SECRET']

@app.post('/webhooks/rozo')
def rozo_webhook():
    ts = request.headers.get('X-Rozo-Timestamp', '')
    sig = request.headers.get('X-Rozo-Signature', '')
    raw = request.get_data(as_text=True)

    if abs(int(time.time() * 1000) - int(ts)) > 5 * 60 * 1000:
        return jsonify(error='timestamp_expired'), 401

    expected = hmac.new(SECRET.encode(), f'{ts}.{raw}'.encode(), hashlib.sha256).hexdigest()
    received = sig.removeprefix('sha256=')
    if not hmac.compare_digest(expected, received):
        return jsonify(error='invalid_signature'), 401

    event = json.loads(raw)
    if db.already_processed(event['event_id']):
        return jsonify(ok=True)

    # event['data'] is the same Payment object as GET /payment-api/payments/{id}
    if event['type'] == 'payment_payout_completed':
        fulfill_order(event['data']['id'], event['data']['destination']['txHash'])

    db.mark_processed(event['event_id'])
    return jsonify(ok=True)
```

### Go

```go
package main

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

func handle(w http.ResponseWriter, r *http.Request) {
    ts := r.Header.Get("X-Rozo-Timestamp")
    sigHdr := r.Header.Get("X-Rozo-Signature")
    raw, _ := io.ReadAll(r.Body)
    secret := os.Getenv("ROZO_WEBHOOK_SECRET")

    n, _ := strconv.ParseInt(ts, 10, 64)
    if abs(time.Now().UnixMilli()-n) > 5*60*1000 {
        http.Error(w, `{"error":"timestamp_expired"}`, 401)
        return
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(ts + "." + string(raw)))
    expected := hex.EncodeToString(mac.Sum(nil))
    received := strings.TrimPrefix(sigHdr, "sha256=")
    if !hmac.Equal([]byte(expected), []byte(received)) {
        http.Error(w, `{"error":"invalid_signature"}`, 401)
        return
    }

    var ev map[string]any
    json.Unmarshal(raw, &ev)
    // dedupe on ev["event_id"]; payment fields are under ev["data"].
    w.Write([]byte(`{"ok":true}`))
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }
```

***

## 7. Delivery Semantics — Read This Carefully

Rozo webhooks are **at-most-once delivery**. This is a design choice, not a gap.

| Question                                 | Answer                                                                                                                                                         |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| How many times is each event delivered?  | **At most once.** No retries. Whether your server returns 2xx, 5xx, or times out, Rozo will not re-send the same event                                         |
| What happens on failure?                 | The delivery is recorded in the dashboard log as `exhausted`. **Reconciliation is on you** — call `GET /payment-api/payments/{id}` to check the current status |
| Are deliveries ordered?                  | Best-effort by event time. Do not rely on strict ordering — your handler should tolerate `payment_payout_completed` arriving before `payment_payin_completed`  |
| Are deliveries deduplicated?             | One POST per delivery, identified by `event_id`. Dedupe on `event_id` to be safe against your own proxies / framework retries                                  |
| How do I dedupe?                         | Use `event_id` as the idempotency key. It is a UUID, globally unique, and equal to the `deliveryId` shown in the dashboard delivery log                        |
| Will I get an email if a delivery fails? | No. Use the dashboard delivery log + your own monitoring                                                                                                       |

**Practical implication:** for any payment your business cares about, **don't rely on the webhook alone**. Your server should also poll `GET /payment-api/payments/{id}` (or `/payments/order/{appId}/{orderId}`) periodically until you observe a terminal status. The webhook is a fast-path optimization on top of polling — it saves you a poll, but it isn't the source of truth, and it does not fire on failure terminals.

***

## 8. Inspect Deliveries in the Dashboard

`Settings → Webhooks → Recent deliveries` shows every POST Rozo has tried to send to your URL. Each row contains:

* `deliveryId` — UUID of this delivery, **equal to the `event_id`** on the webhook body. If you store `event_id` in your handler, paste it here to find the matching record
* `paymentId` — the order it relates to
* `eventType` — the event that triggered the delivery (`payment_payin_completed` or `payment_payout_completed`)
* `statusCode` — HTTP status your endpoint returned
* `responsePreview` — first 200 chars of your response body
* `deliveredAt` — when Rozo POSTed
* `status` — `delivered` (2xx) or `exhausted` (anything else)

`attemptNumber` is always `1` (at-most-once delivery — no retries by design).

You can also fetch this programmatically:

```bash
curl -H "Authorization: Bearer $JWT" \
  "https://aozudqtlykbhzbuzalzz.supabase.co/functions/v1/merchant-api/me/webhook/deliveries?limit=50"
```

***

## 9. Test Your Endpoint

### a. Expose your localhost

```bash
ngrok http 3000
# → https://abc123.ngrok.io
```

Set `https://abc123.ngrok.io/webhooks/rozo` as your webhook URL in the dashboard.

### b. Send a signed test event

```bash
#!/usr/bin/env bash
SECRET="paste-your-64-hex-secret-here"
URL="https://abc123.ngrok.io/webhooks/rozo"
TIMESTAMP=$(date +%s%3N)

# Body must be a single canonical JSON string — sign exactly the bytes you POST.
BODY='{"event_id":"00000000-0000-0000-0000-000000000001","type":"payment_payout_completed","timestamp":"2026-05-09T00:00:00.000Z","data":{"id":"test-payment-id","appId":"merchant_hellocafe","status":"payment_payout_completed","source":{"senderAddress":"0x1111","txHash":"0xaaaa","amountReceived":"10.00"},"destination":{"txHash":"0xbbbb","confirmedAt":"2026-05-09T00:00:00.000Z"}}}'

SIG=$(printf '%s' "${TIMESTAMP}.${BODY}" \
  | openssl dgst -sha256 -hmac "$SECRET" \
  | awk '{print $2}')

curl -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "X-Rozo-Timestamp: $TIMESTAMP" \
  -H "X-Rozo-Signature: sha256=$SIG" \
  --data-binary "$BODY" -i
```

Your server should respond with `200`.

### c. Negative tests

* **Wrong signature** — replace the hex with garbage. Expect `401`.
* **Stale timestamp** — set `TIMESTAMP=$(( $(date +%s%3N) - 400000 ))`. Expect `401`.
* **Replayed (id, status)** — send the same body twice. Expect both `200`, but business logic runs only once.

### d. End-to-end with a real payment

Create a small live payment for your `appId` (any valid `POST /payment-api/payments` call where the `appId` is yours), pay it on-chain, and watch the dashboard delivery log + your own server logs.

You should observe two deliveries to your URL: one `payment_payin_completed` after the buyer's payin lands, then one `payment_payout_completed` after the destination payout confirms.

***

## 10. Identifying Which Secret Is Active

The dashboard shows the **fingerprint** of the active secret — a SHA-256 hash of the plaintext, displayed as `sha256:<64 hex chars>`. To confirm your server is using the right secret, compute the fingerprint locally:

```bash
printf '%s' "$ROZO_WEBHOOK_SECRET" | openssl dgst -sha256 | awk '{print $2}'
```

Compare with the value shown in `Settings → Webhooks`. They must match.

> **Note:** the dashboard exposes the full fingerprint as the canonical identifier — there is no short alias (e.g. `whsec_••••abcd`) by design. If you want a compact tag for your own logs, use the first 8 hex chars of the fingerprint — it's stable until the next rotation.

After rotating, the fingerprint changes; `webhookSecretRotatedAt` is also updated so you can audit when the change happened.

***

## 11. Security Checklist

* [ ] Secret stored in environment variable / secrets manager — never in code.
* [ ] Signature verified on every request **before** any side effects.
* [ ] Timestamp checked — reject anything more than 5 minutes off.
* [ ] Constant-time signature comparison.
* [ ] Raw request bytes used for signing (no re-serialize).
* [ ] Idempotent on `event_id`.
* [ ] HTTPS enforced on your endpoint (HTTP refused at registration anyway).
* [ ] Reconciliation job that polls `GET /payments/{id}` for any payment without a terminal webhook within an expected window — webhook is at-most-once and doesn't fire on failures.

***

## 12. Related

* Dashboard: [partners.rozo.ai/settings/webhooks](https://partners.rozo.ai/settings/webhooks)
