Skip to main content
Anything that reports sales over a network will eventually report the same sale twice. A payment provider redelivers a webhook, a job queue replays a message, a deploy restarts a worker mid-batch, someone re-runs a backfill script. Without a way to recognise the repeat, each one becomes another sale and another commission on the same money. Referly has no Idempotency-Key header. Deduplication works off the identifiers you already have — the charge ID, the invoice ID, the order number from the system that owns the payment.

The two identifiers

Both are optional fields on the sales endpoint, and both do the same job from different angles. Send at least one. Sending both is better — a retry matches if either one has been seen before, so two identifiers means two chances to catch the duplicate.
Send neither and there is no deduplication at all. Referly skips the check entirely when both fields are absent, so every retry writes another sale. This is the single most common cause of affiliates being paid twice.

What happens on a retry

Referly looks for an existing sale in your program carrying either identifier before it writes anything. If it finds one, no sale is created, no commission is calculated, and no sale.created webhook fires. The response to that duplicate call is 200 with a body of null.
That is the same response you get when no affiliate could be resolved, so null on its own doesn’t tell you which happened. If you need to distinguish them, look the sale up by its external ID — the next section shows how. Underneath the application check, the database enforces uniqueness on externalId and on externalInvoiceId within a program. If two identical requests race each other closely enough that both pass the check, the second one is stopped at the database and comes back:
with a 400. Annoying, but safe — it means the duplicate was refused, not recorded. Verify before you retry that one.

Verify whether a sale landed

Look it up by the identifier you sent:
saleExternalInvoiceId works the same way. An empty array means nothing was recorded and it’s safe to send the sale. A result means it’s already there, and the sale in the response tells you which affiliate got the credit and what the commission came out as. This is the check to run whenever a call comes back null or 400 and you can’t tell whether it landed.

Choose identifiers that stay put

The identifier has to be derived from the payment, not from the attempt to report it. Good: the charge ID, the invoice ID, the subscription invoice ID for that billing period, your own order number. Bad: a UUID generated inside the retry, a timestamp, a hash that includes the current time, an incrementing counter local to a worker. Each one changes between attempts, which is exactly what deduplication needs it not to do. Two more rules:
  • Every renewal needs its own identifier. Reuse the subscription ID across renewals and only the first payment is ever recorded. Use the invoice ID for that period instead.
  • Identifiers are unique across the whole program. The same reference cannot be used for two different affiliates. If you prefix your own IDs, prefix per sale, not per affiliate.

What deduplicates and what doesn’t

Because customers deduplicate on email, a retried sale that also names a new customer is only half a problem — you’d get one customer and two sales. The external identifier is what closes the other half.

A retry-safe pattern

Forwarding a payment webhook, with the payment provider’s own IDs carried straight through:
The shape that matters: identifiers come from the charge, the click ID comes from your database, and a failure throws so the queue can retry — safely, because the retry carries the same externalId.

Backfills and migrations

The same rules make a backfill re-runnable. Give every historical sale the identifier it had in the system you’re importing from, and you can run the script as many times as you need — the second pass records nothing. Send a small batch first and check it in the dashboard before running the rest. Sales imported without external identifiers can only be cleaned up one at a time.
Bulk imports also hit rate limits. Space the requests out rather than firing them all at once, or you’ll trade duplicate sales for 429s.

Report a sale

Every field on the sales endpoint.

Capture endpoints

Which public endpoints deduplicate, and which don’t.

Errors

Status codes and what to do about each one.

Webhook retries

How Referly retries the events it sends you.
Last modified on July 21, 2026