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
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
Sign in to partners.rozo.ai.
Open Settings → Webhooks.
Click Rotate secret and confirm.
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
2xxresponse quickly. If your processing is slow, return200immediately 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:
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
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
dataThese 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
typedata 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:
Confirm the timestamp is within 5 minutes of your server clock (replay protection).
Compute the HMAC over
timestamp + "." + raw_bodyusing your webhook secret.Compare with constant-time equality. Never use
===on signature strings.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.
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 theevent_idon the webhook body. If you storeevent_idin your handler, paste it here to find the matching recordpaymentId— the order it relates toeventType— the event that triggered the delivery (payment_payin_completedorpayment_payout_completed)statusCode— HTTP status your endpoint returnedresponsePreview— first 200 chars of your response bodydeliveredAt— when Rozo POSTedstatus—delivered(2xx) orexhausted(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 )). Expect401.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
12. Related
Dashboard: partners.rozo.ai/settings/webhooks
Last updated