For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

{
  "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.

  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:

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.

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

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


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

Python (Flask)

Go


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

  • statusdelivered (2xx) or exhausted (anything else)

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

You can also fetch this programmatically:


9. Test Your Endpoint

a. Expose your localhost

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

b. Send a signed test event

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:

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


Last updated