Callbacks (webhooks)
Receiving and verifying server notifications about payment and payout statuses in Syncra V2
Server notifications (Callbacks / Webhooks)
To keep your system informed about deal status changes, withdrawals, and payment method catalog updates, the Syncra V2 gateway sends asynchronous HTTP POST requests to the callback URL you specify. The delivery address is determined by this hierarchy:
- Per-deal
callback_url— a field of theCreateMerchantPayInrequest (POST /deals/payin): when set, delivery for that deal goes there. merchant.webhook_url— the address configured at the merchant level in the admin panel; used when no per-deal override is set.- Both empty — no callback record is created for the deal (no notification is sent).
Per-deal callback_url is PayIn only. The POST /deals/payout
request has no such field: payout notifications always go to the
merchant's merchant.webhook_url.
callback_url overrides only the delivery address. It does not
waive the deal creation precondition: a webhook_url and a webhook
secret on the merchant account are ALWAYS required (the receiver address
is configured at the account level). Without the account-level setup,
deal creation fails with 400 FAILED_PRECONDITION "merchant webhook URL
is not configured".
Delivery log
Every webhook delivery is recorded as a delivery record. Merchants read their history via the API (also available on the Webhooks tab of the dashboard):
GET /api/v1/p2p/merchant/callbacksThe canonical path lives inside the merchant prefix. The historical
mirror GET /api/v1/p2p/callbacks (without /merchant) keeps working
for wire-compat and is considered deprecated — build new integrations on
the canonical path. The behavior of both bindings is identical.
Parameters (Query)
| Parameter | Type | Description |
|---|---|---|
deal_id | string(UUID) | Filter by deal (optional). The deal_id branch returns the entire delivery trail of that deal without pagination. |
page_size | int32 | Rows per page (1–100, default 20). |
page_token | string | Opaque keyset cursor (a base64-JSON tuple of the previous page's last row created_at,id). An empty string means the first page. |
Response example (200 OK, live staging response)
{
"data": [
{
"callback_id": "8b23b294-8af5-4086-8ea3-2fa8569b30f6",
"deal_id": "9512d99a-3bbc-4a32-acae-d6ad8bb7e32f",
"merchant_id": "3e52c59c-3013-4efa-a2db-70cef2adc7ab",
"callback_url": "https://merchant.example.com/webhook",
"status": "SUCCESS",
"retry_count": 1,
"response_code": 200,
"response_body": "{\"status\":\"ok\"}",
"next_retry_at": "",
"created_at": "2026-08-23T19:14:34Z"
}
],
"next_page_token": "eyJ0IjoiMjAyNi0wOC0yM1QxOToxNDozNC44MzE3OTVaIiwiaWQiOiJiMTFmN2ZiNC0zMTUzLTRlZDktOGExZS05MjNlNzlhMGQ4NTQifQ=="
}An empty next_page_token means the last page. The cursor is opaque —
pass it back as is.
Callback record fields
| Field | Type | Description |
|---|---|---|
callback_id | string(UUID) | Delivery record identifier. |
deal_id | string(UUID) | The deal the notification is about. |
merchant_id | string(UUID) | The owner merchant. |
callback_url | string | Delivery address (the per-deal override or the merchant's webhook_url). |
status | string | PENDING (queued) / SUCCESS (delivered, 2xx) / RETRY (error, waiting for the automatic retry) / ERROR (attempts exhausted — terminal). |
retry_count | int32 | Number of delivery attempts. |
response_code | int32 | The HTTP code of the last response from your endpoint. |
response_body | string | The last response body (truncated). |
next_retry_at | string(RFC3339) | Time of the next automatic attempt (empty if no retries are planned). |
created_at | string(RFC3339) | Record creation time. |
Automatic retry schedule (V2)
A delivery counts as successful on any response below 400 (including
2xx and 3xx); a 4xx/5xx response, a network error, or exceeding
webhook_timeout_sec (default 10 s) counts as a failure. A failed delivery
is retried on a schedule of exponential pauses (the pause after the N-th
failed attempt):
| Failed attempt | Pause before the next attempt |
|---|---|
| 1 | 5 s |
| 2 | 30 s |
| 3 | 5 min |
| 4 | 15 min |
| 5 and later | 1 h |
With the default limit (max_retries = 5, per merchant) a delivery is
attempted exactly 5 times in total — the original attempt plus 4
retries (pauses of 5 s → 30 s → 5 min → 15 min); the 1 h step is reachable
only with a raised per-merchant max_retries. When the attempts are
exhausted, the record moves to the terminal ERROR and an outbox event
about the exhaustion is emitted. For api_version=v1 there are no
retries — any delivery error is terminal (see V1 compatibility below).
Your endpoint's response body is stored in the delivery log truncated to
2048 bytes (response_body).
Manual callback retry
A record in terminal ERROR (or stuck in RETRY) can be re-queued for
immediate delivery — a forced delivery retry (in the dashboard, on the
Webhooks tab):
POST /api/v1/p2p/merchant/callbacks/{callback_id}/retryThe request body is an empty JSON object {}. Authentication: the HMAC
pair or a dashboard JWT (dual-homed).
Rules (in the order they are checked)
- Only your own callback: someone else's
callback_id→403 PERMISSION_DENIED(cannot access another merchant's resources), a non-existent one →404(callback not found). - Only
ERROR/RETRY: forPENDING/SUCCESS→400 FAILED_PRECONDITION(see the live example below). - A 60-second cooldown per callback (independent of the automatic
attempts): calling retry earlier →
429 RESOURCE_EXHAUSTED.
Successful response example (200 OK)
The re-queued record is returned: status=PENDING, next_retry_at≈now,
response fields cleared, retry_count preserved (a manual retry is an
extra attempt in the same trail; the admin reset, unlike the merchant one,
zeroes the counter).
Live staging errors
400 — the callback is not in an error state (live response):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "{\"code\":9,\"message\":\"callback is not in a retryable state (only ERROR and RETRY): status SUCCESS\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}404 — unknown callback_id (live response):
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "{\"code\":5,\"message\":\"callback not found: callback not found\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}429 — 60 s cooldown:
{
"type": "about:blank",
"title": "Too Many Requests",
"status": 429,
"detail": "{\"code\":8,\"message\":\"last manual retry 12s ago (cooldown 1m0s)\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}Request signature (Webhook Signature)
Every outgoing V2 webhook is signed with the same HMAC-SHA256
algorithm used to verify incoming merchant requests (merchantsig). The
signature is sent in an HTTP header:
- V2:
X-Signature: t={unix_timestamp},v1={hex_hmac_sha256} - V1 compat:
SIGNATURE(HMAC-SHA512 of{startOfDayUTC};{salt})
In V2 the header is called X-Signature (NOT X-Syncra-Signature).
Implementation: callback_worker.go:201 — req.Header.Set("X-Signature", cb.Signature). For legacy merchants with api_version=v1, the
SIGNATURE header (without the X- prefix) is additionally set.
V2 header format
X-Signature: t=1783856120,v1=9e87d0c3c8801d0a5198d023b6b19a16f2c8d2037920ab3e09819cd8e412cfabt— the Unix timestamp (UTC) of the moment the signature was computed.v1— the hex HMAC-SHA256 of the string"{t}.{raw_body}", whereraw_bodyis the raw request body (no normalization), the key being the merchant'swebhook_secret.
The signature and the request body are guaranteed to share one byte
source: v1 is computed over the body bytes exactly as they will be
delivered (fix of 2026-08-22: previously the signature could be
computed over a serialization different from the delivered bytes, so an
HMAC computed by the merchant over the received body did not match).
Verify against the received bytes — they will match.
V1 compat format (for migrating merchants)
In legacy V1 webhooks, the signature timestamp is computed strictly as the start of the current UTC day (midnight, 00:00:00):
timestamp = DateTime.UtcNow.Date.ToUnixTimestamp()
signature = HMACSHA512("{timestamp};{salt}", merchantSecret)V1 spec: no retries — any delivery error is terminal.
V2 notification format
The body is JSON, the CallbackEventPayload structure (11 base fields;
deal.amount_changed adds old_amount and amount_version) for
deal/payout/withdrawal lifecycle events:
{
"event": "deal.completed",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "COMPLETED",
"timestamp": "2026-07-12T17:24:12Z",
"received_amount": 250050,
"amount_match_status": "full",
"provider_metadata": {
"bank_code": "sbp",
"rrn": "123456789012"
}
}The exception is the catalog event payment_methods.updated: it uses a
separate envelope without the deal fields (amount, currency,
deal_id) — see
Payment method catalog events.
CallbackEventPayload fields
| Field | Type | Required | Description |
|---|---|---|---|
event | string | Yes | Event name (see the table below). |
deal_id | string(UUID) | Yes | Deal identifier (Order.ID or PayOut.ID for payout events, withdrawal_id for withdrawal events). |
merchant_id | string(UUID) | Yes | Identifier of the merchant who owns the deal. |
tenant_id | string(UUID) | Yes | Tenant identifier. |
amount | int64 | Yes | The amount in minor units (kopecks/cents/satoshi). |
old_amount | int64 | No | The amount BEFORE the change — deal.amount_changed only. |
amount_version | int64 | No | Monotonic counter of amount edits — deal.amount_changed only (a new deal starts at 1, each edit adds 1). |
currency | string | Yes | ISO 4217 (RUB, KZT, ...). For USDT withdrawals — USDT. |
status | string | Yes | The current canonical status: OrderStatus (PayIn), PayOutStatus (PayOut), WithdrawalStatus (withdrawal). |
timestamp | string(RFC3339) | Yes | UTC time the event was produced. |
received_amount | int64 | No | The amount actually received from the payer. PayIn only, after verification. |
amount_match_status | string | No | PayIn amount match result: full / partial / overpaid (strictly lowercase). |
provider_metadata | object | No | Provider-specific metadata (sensitive data such as PAN is redacted). |
fee_breakdown | object | No | Your merchant-side picture: gross amount, your commission, your net (+ a promo line when a promo applies); internal platform costs are not disclosed. deal.completed (PayIn) only, V2 only — full field map and examples: Deposit promo bonuses. |
All int64 values in webhook bodies are transmitted as strings — the
same rule as in REST responses (proto3 JSON): "amount": "330000",
"amount_version": "2". One amount parser for the whole integration; a
bare JSON number never arrives. In the V1 envelope, the format inherits
the legacy V1 contract.
amount_match_status is transmitted strictly lowercase: full,
partial, overpaid. The values MATCHED, UNDERPAID, OVERPAID
(uppercase) do not exist in V2 — they are legacy artifacts.
Payment details are never transmitted in webhooks. Webhook bodies
(the V2 CallbackEventPayload and the legacy V1 envelope) carry only
identifiers, amounts, currency, and status: full PAN/IBAN/phone numbers
are not sent to the merchant (PII minimization). The source of payment
details for PayIn is the public GET /api/v1/p2p/checkout/{hash} (see
SDK Examples
§9),
and for DIRECT deals — additionally the creation response and the owner
merchant's GET /deals/{deal_id} (see
PayIn → Direct mode
(H2H)).
In the V1 envelope the cardNumber field arrives masked only
(**** **** **** 1234 — the last 4 digits); in provider_metadata,
sensitive values (PAN, IBAN, phone numbers) are redacted before sending.
Event types (event)
PayIn/PayOut events use the deal.* prefix (the merchant webhook) or
p2p.deal.* (internal Kafka). USDT withdrawal events have a separate
name.
PayIn events (OrderStatus transitions)
| Event name | Deal status | When it is sent |
|---|---|---|
deal.created | INITIALIZED | A PayIn deal was created. |
deal.escrow_locked | ESCROW_LOCKED | The escrow hold is reserved, payment details issued. |
deal.payment_notified | PAYMENT_NOTIFIED | The client pressed "I paid". |
deal.amount_changed | (no status change) | An operator changed the amount of a live deal (before PAYMENT_VERIFIED). The payload carries old_amount, the new amount, and amount_version. V2 only — see the note below. |
deal.payment_verified | PAYMENT_VERIFIED | The incoming funds are confirmed, the amount is reconciled. |
deal.completed | COMPLETED | The PayIn is finally completed successfully. |
deal.expired | EXPIRED | The deal expired by payment_timeout_at. |
deal.appealed | APPEALED | An appeal was opened on the deal. |
deal.refund_pending | REFUND_PENDING | Transit notification: the deal is recognized as third-party (a rejected appeal, a third-party flag) and is finalized with a cancellation. It is always followed by deal.cancelled — a guaranteed pair; no handling is needed beyond waiting for deal.cancelled. |
deal.cancelled | CANCELLED | The client or the merchant explicitly canceled the deal before payment confirmation. |
deal.amount_changed is available to V2 merchants only: the legacy
V1 envelope has no callbackType for this event (the mapper knows only
the deal status codes 1/2/11/12/13/14/15/16/21), so it is not delivered
to V1 merchants — same as catalog events. This is contractual behavior,
not an omission. A settlement deal (a late payment settlement) emits the
regular deal.created … deal.completed set — these events are
available to both API versions; you can recognize a settlement by the
parent_deal_id / origin_appeal_id fields on the deal object.
PayOut events (PayOutStatus transitions)
PayOut events use the same names deal.completed / deal.failed as
PayIn (NOT payout.completed). This is intentional: a PayOut is also a
"deal" in webhook terms. Tell the direction apart by the status field
(PayOutStatus vs OrderStatus) or by the absence of the
received_amount / amount_match_status fields (they exist only for
PayIn).
| Event name | Payout status | When it is sent |
|---|---|---|
deal.created | INITIALIZED | The PayOut is created, the balance is put on hold. |
deal.completed | COMPLETED | The PayOut is executed successfully — the funds are sent to the recipient. |
deal.failed | FAILED | The PayOut is rejected (invalid payment details, cancellation by the trader). The hold is returned to the merchant. |
deal.expired | EXPIRED | The PayOut expired before the transfer completed. |
USDT withdrawal events (WithdrawalStatus)
| Event name | Status | When it is sent |
|---|---|---|
p2p.finance.withdrawal_completed | APPROVED | The on-chain tx is sent, the ledger is debited. |
p2p.finance.withdrawal_failed | CANCELLED | The withdrawal is rejected (admin / pre-broadcast cancel). |
In Kafka, the same transitions are published with the p2p.deal.*
prefix for internal consumers. V2 merchant webhooks are deal.* without
the prefix; withdrawals are p2p.finance.withdrawal_*.
Payment method catalog events
| Event name | When it is sent |
|---|---|
payment_methods.updated | An administrator created or changed the catalog-visible fields of a payment method (name, status, requisite_types, is_cross_border). Broadcast to all merchants of the tenant. |
payment_methods.updated is V2 only (api_version = v2). The
legacy V1 envelope has no notification type for catalog events
(callbackType 1/2/3/10), so the event is not sent to V1 merchants:
delivery goes only to active V2 merchants with a configured non-empty
webhook_url.
An update with identical values (a no-op) is NOT emitted.
The catalog event envelope is a separate structure (NOT
CallbackEventPayload): the amount / currency / deal_id fields are
absent — the event describes a reference data change, not a
transaction:
{
"event": "payment_methods.updated",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-08-27T12:00:00Z",
"data": {
"changed": [
{
"payment_method_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"slug": "sbp_rub",
"status": "DISABLED",
"name": "SBP",
"before": {
"status": "ACTIVE",
"name": "SBP",
"requisite_types": ["SBP"]
}
}
]
}
}The data.changed[] element carries the AFTER state of the method
(payment_method_id, slug, status, name); on updates it
additionally carries before — a snapshot of the catalog-visible fields
prior to the change (status, name, requisite_types,
is_cross_border). When a method is created, before is absent.
The event is a notification, not a data source. Recommendation: on
payment_methods.updated, re-read the current catalog via
GET /api/v1/p2p/merchant/payment-methods
and cache it until the next event. Do not build a local copy of the
catalog from the payload alone — the event does not carry the full
method list.
Delivery and signature are the same as for lifecycle events:
X-Signature (HMAC-SHA256 of {t}.{raw_body} with the merchant's
webhook_secret key), retries on the schedule above. The delivery record
in the log (GET /api/v1/p2p/merchant/callbacks) is linked to neither a
deal nor a payout (the corresponding fields are NULL).
Payload examples by event
deal.amount_changed — an operator changed the amount of a live deal
(before payment verification). amount carries the NEW amount,
old_amount the previous one, amount_version the edit number
(monotonic, starts at 1 for a new deal): escrow holds are recreated for
the new amount, and the USDT equivalent is recomputed at the deal's own
rate. The deal status does not change:
{
"event": "deal.amount_changed",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 200000,
"old_amount": 150000,
"amount_version": 2,
"currency": "RUB",
"status": "PAYMENT_NOTIFIED",
"timestamp": "2026-09-03T18:12:44Z"
}deal.payment_verified — the incoming funds are confirmed (amount
match):
{
"event": "deal.payment_verified",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "PAYMENT_VERIFIED",
"timestamp": "2026-07-12T17:23:45Z",
"received_amount": 250050,
"amount_match_status": "full"
}deal.completed (PayIn) — successful completion of an incoming
payment:
{
"event": "deal.completed",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "COMPLETED",
"timestamp": "2026-07-12T17:24:12Z",
"received_amount": 250050,
"amount_match_status": "full"
}Since 2026-09-17, V2 merchants additionally receive the fee_breakdown
object in deal.completed (PayIn) — only your picture: the amount, your
commission, your net (+ a promo bonus line when a promo applies); the
breakdown of internal platform costs is not disclosed. The field is
additive (its absence in older deliveries is not an error), and the V1
envelope receives no new fields. Full field map and examples —
Deposit promo bonuses.
deal.expired (PayIn) — the deal expired by payment_timeout_at, no
funds arrived (the terminal unsuccessful outcome of an incoming payment).
The received_amount and amount_match_status fields are absent — no
amount match was performed:
{
"event": "deal.expired",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "EXPIRED",
"timestamp": "2026-07-12T17:55:00Z"
}deal.cancelled (PayIn) — the deal was explicitly canceled (by the
client on the payment page or by the merchant via the cancel API) before
payment confirmation. Like deal.expired, a terminal unsuccessful
outcome: the received_amount and amount_match_status fields are
absent — no amount match was performed:
{
"event": "deal.cancelled",
"deal_id": "7ac148fe-19a3-45bb-b992-019fac55b721",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 250050,
"currency": "RUB",
"status": "CANCELLED",
"timestamp": "2026-07-12T17:30:00Z"
}If your handler filters events by name (for example, reacts only to
deal.completed), add deal.cancelled to the filter — otherwise the
cancellation will bypass your logic and the deal will "hang" waiting for
a final state.
deal.completed (PayOut) — successful completion of a payout (the
funds are sent to the recipient). Note that received_amount and
amount_match_status are absent — these fields are PayIn only:
{
"event": "deal.completed",
"deal_id": "e81d77a0-0d3a-4422-b91c-7cb052a2119c",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 500000,
"currency": "RUB",
"status": "COMPLETED",
"timestamp": "2026-07-12T17:28:00Z"
}deal.failed (PayOut) — the payout is rejected, the hold is
returned:
{
"event": "deal.failed",
"deal_id": "e81d77a0-0d3a-4422-b91c-7cb052a2119c",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 500000,
"currency": "RUB",
"status": "FAILED",
"timestamp": "2026-07-12T17:29:30Z"
}p2p.finance.withdrawal_completed — the USDT cryptocurrency
withdrawal is executed:
{
"event": "p2p.finance.withdrawal_completed",
"deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"merchant_id": "8f870a31-b88c-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 1000000,
"currency": "USDT",
"status": "APPROVED",
"timestamp": "2026-07-12T17:30:05Z"
}For withdrawal events, the deal_id field contains the withdrawal
record identifier (the USDT withdrawal request), not Order.ID — a
withdrawal is not an order.
V2 signature verification
const crypto = require('crypto');
function verifyV2Webhook(rawBody, signatureHeader, webhookSecret) {
// 1. Parse the X-Signature header: t=<unix>,v1=<hex>
const parts = signatureHeader.split(',');
const timestampPart = parts.find(p => p.startsWith('t='));
const signaturePart = parts.find(p => p.startsWith('v1='));
if (!timestampPart || !signaturePart) return false;
const timestamp = timestampPart.substring(2);
const providedSignature = signaturePart.substring(3);
// 2. Replay protection: the timestamp must not be older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
return false;
}
// 3. Compute the expected signature: HMAC-SHA256("{t}.{body}", secret)
const payload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(payload)
.digest('hex');
// 4. Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(providedSignature, 'utf-8'),
Buffer.from(expectedSignature, 'utf-8')
);
}The signature is computed over the raw request body (raw bytes), exactly as it arrived from the network. Do not decode or normalize the JSON before verification — that breaks the signature.
Notification types in the adapter (V1 compatibility)
For legacy merchants with api_version=v1, the callback service builds a
V1 envelope:
{
"callbackType": 1,
"callbackData": {
"paymentId": "7ac148fe-19a3-45bb-b992-019fac55b721",
"payInId": "order_12345",
"clientId": "user_99",
"status": 11,
"currency": 643,
"amount": "2500.50",
"paidAmount": 2500.50,
"cardNumber": "**** **** **** 1234",
"fullName": "Ivan I.",
"callbackUrl": "https://example.com/webhook",
"createdAt": "2026-07-12T17:24:12Z"
},
"salt": 4729103
}V1 notification type mapping (callbackType)
callbackType | Entity | V2 counterpart |
|---|---|---|
1 | Incoming payment (PayIn) | deal.* with OrderStatus |
2 | Outgoing payment (PayOut) | deal.completed / deal.failed with PayOutStatus |
3 | Dispute opened/changed | deal.appealed |
10 | USDT crypto withdrawal | p2p.finance.withdrawal_* |
V1 spec: NO RETRIES. For api_version=v1, any delivery failure is
terminal — there are no retries. The exponential backoff above applies
to V2 merchants only.
Webhook handling best practices
- Return HTTP 200 fast. The handler must immediately return
200 OKwith the body{"status":"success"}. Move heavy business logic to a background queue — store the event first, answer200, then process asynchronously. - Idempotency. Always check whether you have already processed the
event with this
deal_id+event+timestamp. Duplicates are possible because of retries. - Ignore unknown event types. The list of
eventvalues grows over time (new statuses, new entities) — your handler must store/log the unknowneventand answer200, not crash or return an error. Returning an error makes sense only when you actually want a redelivery. - Retry policy (V2). The automatic retry schedule is described above
(section Delivery log); a terminal
ERRORcan be re-queued with a manual retry. - IP allowlisting. For extra protection, you can restrict webhook intake to the list of Syncra gateway IP addresses (the current list is in the control panel).