S
docs.syncra.money
Webhooks

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:

  1. Per-deal callback_url — a field of the CreateMerchantPayIn request (POST /deals/payin): when set, delivery for that deal goes there.
  2. merchant.webhook_url — the address configured at the merchant level in the admin panel; used when no per-deal override is set.
  3. 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/callbacks

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

ParameterTypeDescription
deal_idstring(UUID)Filter by deal (optional). The deal_id branch returns the entire delivery trail of that deal without pagination.
page_sizeint32Rows per page (1–100, default 20).
page_tokenstringOpaque 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

FieldTypeDescription
callback_idstring(UUID)Delivery record identifier.
deal_idstring(UUID)The deal the notification is about.
merchant_idstring(UUID)The owner merchant.
callback_urlstringDelivery address (the per-deal override or the merchant's webhook_url).
statusstringPENDING (queued) / SUCCESS (delivered, 2xx) / RETRY (error, waiting for the automatic retry) / ERROR (attempts exhausted — terminal).
retry_countint32Number of delivery attempts.
response_codeint32The HTTP code of the last response from your endpoint.
response_bodystringThe last response body (truncated).
next_retry_atstring(RFC3339)Time of the next automatic attempt (empty if no retries are planned).
created_atstring(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 attemptPause before the next attempt
15 s
230 s
35 min
415 min
5 and later1 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}/retry

The request body is an empty JSON object {}. Authentication: the HMAC pair or a dashboard JWT (dual-homed).

Rules (in the order they are checked)

  1. 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).
  2. Only ERROR / RETRY: for PENDING / SUCCESS → 400 FAILED_PRECONDITION (see the live example below).
  3. 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=9e87d0c3c8801d0a5198d023b6b19a16f2c8d2037920ab3e09819cd8e412cfab
  • t — the Unix timestamp (UTC) of the moment the signature was computed.
  • v1 — the hex HMAC-SHA256 of the string "{t}.{raw_body}", where raw_body is the raw request body (no normalization), the key being the merchant's webhook_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

FieldTypeRequiredDescription
eventstringYesEvent name (see the table below).
deal_idstring(UUID)YesDeal identifier (Order.ID or PayOut.ID for payout events, withdrawal_id for withdrawal events).
merchant_idstring(UUID)YesIdentifier of the merchant who owns the deal.
tenant_idstring(UUID)YesTenant identifier.
amountint64YesThe amount in minor units (kopecks/cents/satoshi).
old_amountint64NoThe amount BEFORE the change — deal.amount_changed only.
amount_versionint64NoMonotonic counter of amount edits — deal.amount_changed only (a new deal starts at 1, each edit adds 1).
currencystringYesISO 4217 (RUB, KZT, ...). For USDT withdrawals — USDT.
statusstringYesThe current canonical status: OrderStatus (PayIn), PayOutStatus (PayOut), WithdrawalStatus (withdrawal).
timestampstring(RFC3339)YesUTC time the event was produced.
received_amountint64NoThe amount actually received from the payer. PayIn only, after verification.
amount_match_statusstringNoPayIn amount match result: full / partial / overpaid (strictly lowercase).
provider_metadataobjectNoProvider-specific metadata (sensitive data such as PAN is redacted).
fee_breakdownobjectNoYour 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 nameDeal statusWhen it is sent
deal.createdINITIALIZEDA PayIn deal was created.
deal.escrow_lockedESCROW_LOCKEDThe escrow hold is reserved, payment details issued.
deal.payment_notifiedPAYMENT_NOTIFIEDThe 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_verifiedPAYMENT_VERIFIEDThe incoming funds are confirmed, the amount is reconciled.
deal.completedCOMPLETEDThe PayIn is finally completed successfully.
deal.expiredEXPIREDThe deal expired by payment_timeout_at.
deal.appealedAPPEALEDAn appeal was opened on the deal.
deal.refund_pendingREFUND_PENDINGTransit 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.cancelledCANCELLEDThe 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 namePayout statusWhen it is sent
deal.createdINITIALIZEDThe PayOut is created, the balance is put on hold.
deal.completedCOMPLETEDThe PayOut is executed successfully — the funds are sent to the recipient.
deal.failedFAILEDThe PayOut is rejected (invalid payment details, cancellation by the trader). The hold is returned to the merchant.
deal.expiredEXPIREDThe PayOut expired before the transfer completed.

USDT withdrawal events (WithdrawalStatus)

Event nameStatusWhen it is sent
p2p.finance.withdrawal_completedAPPROVEDThe on-chain tx is sent, the ledger is debited.
p2p.finance.withdrawal_failedCANCELLEDThe 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 nameWhen it is sent
payment_methods.updatedAn 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)

callbackTypeEntityV2 counterpart
1Incoming payment (PayIn)deal.* with OrderStatus
2Outgoing payment (PayOut)deal.completed / deal.failed with PayOutStatus
3Dispute opened/changeddeal.appealed
10USDT crypto withdrawalp2p.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

  1. Return HTTP 200 fast. The handler must immediately return 200 OK with the body {"status":"success"}. Move heavy business logic to a background queue — store the event first, answer 200, then process asynchronously.
  2. Idempotency. Always check whether you have already processed the event with this deal_id + event + timestamp. Duplicates are possible because of retries.
  3. Ignore unknown event types. The list of event values grows over time (new statuses, new entities) — your handler must store/log the unknown event and answer 200, not crash or return an error. Returning an error makes sense only when you actually want a redelivery.
  4. Retry policy (V2). The automatic retry schedule is described above (section Delivery log); a terminal ERROR can be re-queued with a manual retry.
  5. 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).

On this page