> 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)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## 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, and the optional `goal` query parameter:

```
GET https://docs.rozo.ai/integration/api-doc/merchant-api/webhook.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
