Postback (S2S conversions)
The MeerPartners server-to-server postback: how to report a conversion to the platform. Request fields, idempotency by external_order_id, responses and rejection codes. Examples in cURL, Node.js and Python.
A postback is a server-side notification from your backend to the platform that a conversion has occurred on your side: a sale, lead, registration, refund or chargeback. This is the core integration mechanism for a business: it is from the postback that the platform creates the conversion, attributes it to the partner and accrues the commission.
/api/v1/postback🔒 HMACThe request is signed with HMAC over the secret of an API key with the postback scope. The signing algorithm and headers (X-Api-Key-Id, X-Timestamp, X-Signature, and the optional X-Tenant-Id) are on the API keys and signing page. Without a valid signature the request is not processed.
The click comes first
For a conversion to have something to be attributed to, you must have the user's click_id. The partner's tracking link issues it as aff_click during the redirect. Store it next to the order and pass it to the postback as ref. See Tracking links and macros.
Request fields
The body is JSON. Content-Type: application/json.
external_order_idstringrequiredA unique order/event ID in your system (1–128 characters). This is the idempotency and deduplication key — the platform uses it to tell a retry apart from a new event.
event_typestringrequiredThe event type: sale, lead, registration, refund, chargeback.
refstringoptionalAttribution: a click_id (the aff_click value from the redirect) or the tracking link code. Required for sale/lead/registration; not needed for refund/chargeback (the event finds the original conversion by external_order_id).
sub_idstringoptionalThe partner's pass-through label (up to 128 characters), if you passed it through and want to keep it in the conversion.
amountstringoptionalThe order amount as a string (to avoid precision loss), e.g. "19.99". Required for sale together with currency.
currencystringoptionalThe amount's currency, ISO-4217, 3 letters — "USD", "RUB". Required for sale.
statusstringoptionalapproved or pending. Affects how the conversion starts; by default a conversion starts in pending and goes through the hold period.
occurred_atstringoptionalThe event time on your side in RFC 3339 format (2026-06-01T10:15:00Z). Defaults to the moment of receipt.
metaobjectoptionalArbitrary data (plan, promo code, etc.). Stored in the conversion for analytics and investigation.
Required fields depend on event_type
For sale you need external_order_id, event_type, ref, amount, currency. For lead/registration — external_order_id, event_type, ref (amount is optional). For refund/chargeback — only external_order_id and event_type. If a required field is missing — POSTBACK_MISSING_FIELD (422).
Idempotency
The platform deduplicates events by the pair (your tenant, external_order_id) and the event type — under an advisory lock, so even a race between two identical postbacks will not create two conversions.
- The first successful postback creates the conversion and the commission.
- A retry with the same
external_order_id→ the same result and"duplicate": true, with no side effects.
This means it is safe to retry: resending will not double the accrual.
Responses
The response is a "bare" JSON object (no data wrapper). HTTP code 200 on a successful or duplicate processing.
{
"accepted": true,
"duplicate": false,
"conversion_id": 7741,
"commission": {
"id": 7741,
"amount": "2.50",
"currency": "USD",
"status": "pending",
"hold_until": "2026-06-15T10:15:00Z"
}
}{ "accepted": true, "duplicate": true, "conversion_id": 7741 }{ "accepted": false, "reason": "POSTBACK_UNKNOWN_REF" }| Response field | Value |
|---|---|
accepted | true if the event was taken for processing; false for organic/non-applicable refund |
duplicate | true if this is a retry of an already-processed external_order_id |
conversion_id | The ID of the created (or found) conversion |
commission | Brief info on the zero-level commission: id, amount, currency, status, hold_until (only on successful attribution to a partner) |
reason | The reason when accepted: false (e.g. POSTBACK_UNKNOWN_REF) |
accepted:false is not a retryable error
A 200 response with accepted: false (no attribution, or a refund for a non-existent conversion) is final. It is not a connectivity failure: it means the sale is organic or the click was not found. Do not retry such responses in a loop — handle them as a normal outcome. A retry is justified only on 5xx/timeout.
Rejection codes
| Code | HTTP | When | Reaction |
|---|---|---|---|
TENANT_API_KEY_INVALID | 401 | Key is unknown, revoked or expired | Check X-Api-Key-Id/secret, do not retry |
TENANT_API_KEY_SCOPE_DENIED | 403 | The key lacks the postback scope | Issue a key with the postback scope |
POSTBACK_BAD_SIGNATURE | 401 | The HMAC signature did not match | Check the signing algorithm and that you signed the same bytes |
POSTBACK_STALE_TIMESTAMP | 400 | X-Timestamp outside the ±300 s window | Sync the clock (NTP), rebuild the request |
POSTBACK_REPLAY | 409 | The same signature replayed within the memory window | Build a new request with a current X-Timestamp |
POSTBACK_TENANT_MISMATCH | 403 | X-Tenant-Id did not match the key's tenant | Remove/fix X-Tenant-Id |
POSTBACK_MISSING_FIELD | 422 | A required field for the event_type is missing | Add the missing field |
POSTBACK_UNKNOWN_REF | 200 | No attributable click (accepted:false) | Normal outcome — do not retry |
POSTBACK_REFUND_NO_CONVERSION | 200 | Refund for a non-existent conversion (accepted:false) | Normal outcome — do not retry |
POSTBACK_RATE_LIMITED | 429 | Too many postbacks per key | Slow down, retry with backoff |
SERVICE_UNAVAILABLE | 503 | The DB/store is unavailable | Retry with exponential backoff |
The full error envelope format ({ "error": { "code", "message", "details" } }) is on the API error codes page.
Retry strategy
Retry only 5xx and network timeouts — with exponential backoff. On any 2xx (including accepted:false) — stop. On 4xx (except 409 POSTBACK_REPLAY, where you must rebuild the signature) — stop and alert: this is usually an integration bug, not a transient failure.
Full example
A sale conversion with signature computation. The base URL is {API_BASE} (the exact domain is provided by the operator).
API_BASE="https://api.meerpartners.com"
KEY_ID="key_3f9a1c"
SECRET="sk_live_9f2a…"
BODY='{"external_order_id":"ORD-558123","event_type":"sale","ref":"clk_5f2a9e","amount":"19.99","currency":"USD","status":"approved"}'
TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
SIGNING=$(printf '%s\n%s\n%s\n%s' "POST" "/api/v1/postback" "$TS" "$BODY_HASH")
SIG=$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -sS -X POST "$API_BASE/api/v1/postback" \
-H "Content-Type: application/json" \
-H "X-Api-Key-Id: $KEY_ID" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
--data "$BODY"Test before going live
Before connecting a live stream, run the test postback from the cabinet (POST /api/v1/merchant/integration/test-postback) — it goes through the real flow but is safe for checking the key ↔ offer binding.