Engineering

Idempotency keys and signed webhooks: the two things a reseller API must get right

An idempotency key makes a retried purchase safe and a signed webhook makes an event trustworthy. Here is what the specs say and how to check a provider.

The short answer

A reseller API spends money at a third party, so it needs two guarantees: a retried purchase must never buy twice, and an event must be provably from the provider. The first comes from an Idempotency-Key header with a published retention window and clear 409 and 422 behaviour; the second from signed webhooks with a timestamp, a stable message id and multi-day retries. The IETF Idempotency-Key document is an expired draft, not an RFC, and Standard Webhooks sets no numeric replay tolerance, so read each provider's own documentation.

A reseller API must make a retried purchase safe and make every event it sends verifiable. Idempotency keys do the first job and signed webhooks do the second, and a provider that is vague about either leaves you unable to tell whether a retry will buy twice or whether an order went through.

Both matter more for resellers than for most API users, because the side effect is at a third party. A registered domain, a rented phone number or a block of licence seats cannot be undone by rolling back a database row.

Why these two things matter more for reseller APIs

A reseller API turns one HTTP call into a debit on your balance and an order at an upstream supplier, so a lost response is expensive. Suppose you send "register example.com" and the connection drops after 30 seconds. Three things may be true: the request never arrived, it arrived and failed, or it arrived, the registry accepted it, and only the answer was lost. From the outside these look identical.

If you retry blindly, the third case becomes a second charge or a confusing "domain taken" error for a domain you now own. If you do not retry, the first case becomes a customer waiting for a domain that was never ordered. The idempotency key resolves this on the way in: the retry carries the same key, and the server answers with whatever happened the first time.

Webhooks resolve the same uncertainty on the way out. Provisioning is often slow or asynchronous, so the provider tells you later that the order was filled, the renewal failed or the number received an SMS. That message arrives on a public URL, which means anyone can post to it. Without a signature you cannot tell a real domain.registered from a forged one. Prepaid balances raise the stakes further, which is covered in prepaid wallet billing for resellers.

What the IETF draft actually specifies

The IETF document describes a client-generated Idempotency-Key request header, but it is an expired Internet-Draft, not an RFC. Version -07 is dated 15 October 2025 and carries an expiry of 18 April 2026 (IETF draft -07), and as of 7 October 2026 the Datatracker lists it as expired and archived (IETF Datatracker). Articles that call it "the standard" are wrong, though it remains the clearest written description of the pattern.

The IETF Datatracker page for the Idempotency-Key header draft

The Idempotency-Key draft reached revision 07 and is shown as an expired Internet-Draft. Screenshot of datatracker.ietf.org, taken on 7 October 2026.

What the draft says:

  • Purpose. The header makes non-idempotent methods such as POST and PATCH fault-tolerant.
  • Uniqueness. The key "MUST be unique and MUST NOT be reused with another request with a different request payload". A UUID or similar random identifier is recommended.
  • Expiry. The server "SHOULD define such expiration policy and publish it in the documentation". The draft sets no retention period itself.
  • Fingerprint. The server may fingerprint the payload, for example with a checksum, to detect a key reused with a different body.
  • Three error cases. 400 when a required key is missing, 422 when a key is reused with a different payload, and 409 when a retry arrives while the original request is still being processed.
  • Replay. A retry after completion should receive the result of the previously completed operation, "success or an error".

The useful part for an evaluator is the split between 409 and 422. A 409 means "wait and send the same thing again". A 422 means "your client has a bug". A provider that returns the same code for both leaves your retry logic guessing.

How Stripe and Adyen implement idempotency keys

Stripe and Adyen both document their idempotency keys in public, including a retention window, and they differ on the numbers. The table compares them with the draft, as of 7 October 2026.

IETF draft -07StripeAdyen
StatusExpired draftProduction APIProduction API
Maximum key lengthNot specified255 characters64 characters
RetentionNone set; publish your policyPrunable once at least 24 hours oldValid for 7 to 14 days
Same key, different body422Returns an errorNot stated on the page read
Duplicate while first is running409Not saved; can be retried422 or 409
Stored errors are replayedYes, "success or an error"Yes, including 500 errorsNot stated on the page read

Sources: IETF draft -07, Stripe idempotent requests, Adyen API idempotency.

The Stripe documentation on idempotent requests

Stripe documents how it stores the result of the first request made with a key and replays it. Screenshot of docs.stripe.com, taken on 7 October 2026.

Two details from Stripe's documentation are worth copying into your own mental model. First, the stored result includes failures: a request that ended in a 500 returns the same 500 on retry with that key, so a new attempt needs a new key. Second, results are saved only once the endpoint starts executing, so a request that fails validation can be corrected and retried. Stripe also advises against putting sensitive data such as email addresses in a key. Adyen scopes keys to the company account (Adyen), which is the sensible default for any multi-tenant API.

On the client side the rule is short: generate the key once per operation, store it with your own order record before sending, and reuse it only to retry that operation.

import { randomUUID } from 'node:crypto';

async function placeOrder(body: unknown, key = randomUUID()) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch('https://api.example.com/v1/orders', {
      method: 'POST',
      headers: {
        authorization: `Bearer ${process.env.API_KEY}`,
        'content-type': 'application/json',
        'idempotency-key': key, // the same key on every attempt
      },
      body: JSON.stringify(body),
    }).catch(() => null); // network failure: outcome unknown, retry

    // 409: the first attempt is still running. 429 and 5xx: retry later.
    const retry = !res || res.status === 409 || res.status === 429 || res.status >= 500;
    if (!retry) return res; // success, or a 4xx that will not change
    await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
  }
  throw new Error(`order ${key} unresolved, check it before retrying`);
}

Whether a 5xx is worth retrying with the same key depends on the provider. On Stripe a stored 500 is replayed, so check each provider's documentation before copying this loop.

What Standard Webhooks specifies

Standard Webhooks is an open specification that fixes three headers, one signed string and two signature schemes. It is published by a project whose steering committee includes people from Zapier, Twilio, Lob, Mux, ngrok, Supabase, Svix and Kong, according to the project's own site as of October 2026. The site also lists companies that use it, a list this article has not checked vendor by vendor.

The Standard Webhooks home page

Standard Webhooks is an open specification for sending webhooks. Screenshot of standardwebhooks.com, taken on 7 October 2026.

From the specification:

  • Headers. webhook-id is a unique message identifier, webhook-timestamp is an integer Unix timestamp in seconds, and webhook-signature holds one or more signatures separated by spaces.
  • Signed content. The id, timestamp and body are joined with full stops: msg_id.timestamp.payload.
  • Schemes. HMAC-SHA256 signatures are prefixed v1 and use a secret prefixed whsec_. Asymmetric ed25519 signatures are prefixed v1a.
  • Rotation. The header is a list so a sender can sign with the old and new secret during a changeover, with no downtime.
  • Payload. A type, a timestamp and a data object, kept small, usually under 20kb.

Stripe uses a different design and does not use these header names. It sends a single Stripe-Signature header containing a timestamp and an HMAC-SHA256 signature over the timestamp and body, with no message id in the signed string (Stripe webhooks). The practical difference is that a Standard Webhooks verification library can be reused with any provider that follows the specification, while Stripe signatures need Stripe's own library or code.

How to verify a webhook signature

Verify a signature by recomputing it from the raw request body and comparing in constant time. A Standard Webhooks library does this for you, and using one is the better choice in production. The manual version shows what is being checked:

import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60; // your choice; the spec gives no number

export function verify(secret: string, headers: Record<string, string>, rawBody: string) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  // The header may carry several signatures during a secret rotation.
  return signatures.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) return false;
    const given = Buffer.from(value, 'base64');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Three mistakes to avoid:

  1. Parsing the body first. The specification warns that the payload verified must be exactly the payload signed, and that even a stray space invalidates the signature, which is what happens when JSON is parsed and serialised again (Standard Webhooks). Read the raw body, verify, then parse.
  2. Comparing with ===. The specification calls for a constant-time comparison for symmetric signatures.
  3. Skipping the timestamp. A valid signature on an old message is still valid, which is the whole replay problem.

Retries, duplicates and replay handling

Assume every webhook can arrive late, twice or out of order, and that a captured one can be sent again by an attacker. Each has a different defence.

Replay. Standard Webhooks tells receivers to check that the timestamp is "within some allowable tolerance" and gives no number (specification). The five minutes often attributed to the standard is the default tolerance of Stripe's libraries (Stripe webhooks). Pick a value and write it down.

Duplicates. Under Standard Webhooks the message id stays the same across retries, and the specification suggests using webhook-id as an idempotency key on your side. Record the id in the same transaction as the work it triggers:

// A unique index on webhook_id makes the second insert fail.
const firstTime = await db.processedWebhooks.insertIfAbsent({ webhookId: id });
if (!firstTime) return new Response(null, { status: 204 }); // already handled

Ordering. Stripe states it does not guarantee delivery in the order events were generated (Stripe webhooks). Treat an event as a prompt to read the current state from the API, not as the state itself.

Speed. Stripe advises returning a 2xx before any complex logic (Stripe webhooks). Verify, enqueue, answer, then do the work.

How long a sender keeps trying varies a lot, as of October 2026:

Retry behaviourHow to spot a repeat
Standard Webhooks (example schedule)Exponential backoff with random jitter over multiple days; the example schedule ends 75 hours 35 minutes after the first attemptwebhook-id is unchanged
StripeUp to three days with exponential backoff in live modeLog the event ids you have processed
TwilioOne retry by default, and by default only after a connection failure; the count is configurable from 0 to 5I-Twilio-Idempotency-Token header

Sources: Standard Webhooks, Stripe webhooks, Twilio connection overrides.

A short retry window is not a defect if the provider also offers a way to list or resend missed events. Stripe, for example, lets you resend an event from its dashboard for up to 15 days after it was created (Stripe webhooks). With one retry and no resend, an outage on your side can lose events, so you need a polling job as a backstop.

A checklist for evaluating any provider

A provider passes if its documentation answers every question below without a support ticket. This applies equally to the domain APIs in our domain reseller API comparison and the number APIs in phone number APIs for receiving SMS.

Idempotency

  • Which routes accept a key? Every route that spends money should, including renewals.
  • How long is a key remembered, and is that number published?
  • What happens with the same key and a different body? Expect a distinct error, such as 422.
  • What happens when a retry arrives while the first request is running? Expect a 409 or equivalent.
  • Are failures stored and replayed, and which ones?
  • Are keys scoped to your account?

Webhooks

  • Is every delivery signed, and with which scheme? An unsigned webhook protected only by a secret URL is weaker.
  • Is the timestamp inside the signed content?
  • Is there a stable message id across retries?
  • What is the retry schedule and the response timeout?
  • Can you rotate a secret with two signatures valid at once?
  • Can you list or resend failed deliveries, and for how long?
  • Can you trigger test events in a sandbox? See how to test a reseller API integration in a sandbox for a worked test plan, including a retry with an idempotency key and a webhook with a bad signature.

The two together

  • If a purchase times out, can you find its outcome by key, by order lookup and by webhook? You want at least two of the three.
  • A subscription-style product such as mailbox licences raises a sharper version of the same question, because the request mutates existing state instead of creating something new once. See how to change subscription seat counts without double-charging a customer for a worked example and ResellerHQ's licence_charge_in_doubt error.

Where ResellerHQ fits

ResellerHQ uses the 422 and 409 status codes the IETF draft recommends and sends webhooks in the Standard Webhooks format, within limits you should know about. Per its idempotency guide, every route that spends money accepts an Idempotency-Key of 8 to 200 characters, outcomes are stored for 24 hours, the same key with a different body answers 422, and a retry during processing answers 409. Keys are scoped to the account.

Per its webhooks guide, deliveries carry the three Standard Webhooks headers with an HMAC-SHA256 signature. Your endpoint has 10 seconds to answer with a 2xx. After that, retries follow at intervals of 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 14 hours, which adds up to 31 hours 35 minutes and is shorter than the specification's example schedule. Measured against the checklist above, the guides do not describe rotating a secret with two signatures valid at once, or a way to list or resend failed deliveries, so plan a polling job for anything you cannot afford to miss.

The limits: ResellerHQ is in early access. Domains and mailbox licences run in the sandbox only, and their live routes answer 422 not_available. Live phone numbers depend on an upstream provider being enabled for the account. There is no public price list, and sandbox prices are test prices. The sandbox does sign and deliver webhooks the same way as the live side, so a handler can be tested against it after creating an account.

Questions

  • It is a unique value the client generates and sends with a request so the server can recognise a retry of the same request. The server stores the first outcome and returns it again instead of running the operation a second time.
  • No. It is described in an IETF working group draft whose last version, -07, was published on 15 October 2025 and expired on 18 April 2026. It is the most cited description of the pattern, but it is not an RFC.
  • The IETF draft sets no number and only says the server should publish its expiry policy. Stripe lets keys be pruned once they are at least 24 hours old, and Adyen keeps them valid for 7 to 14 days.
  • A retry with the same key and the same body should return the stored result of the first request. The IETF draft recommends 422 when the key is reused with a different body and 409 when the first request is still being processed.
  • Join the webhook-id header, the webhook-timestamp header and the raw request body with full stops, compute an HMAC-SHA256 with the endpoint secret, and compare the result with the v1 values in webhook-signature using a constant-time comparison. Also reject timestamps that are too old.
  • Senders retry any delivery that does not get a 2xx answer in time, so a slow or failed response produces a second copy. Under Standard Webhooks the webhook-id stays the same across retries, so store the ids you have handled and skip repeats.

Sources

  1. IETF: The Idempotency-Key HTTP Header Field, draft -07 ietf.org
  2. IETF Datatracker: draft-ietf-httpapi-idempotency-key-header status datatracker.ietf.org
  3. Stripe: Idempotent requests docs.stripe.com
  4. Adyen: API idempotency docs.adyen.com
  5. Standard Webhooks: specification github.com
  6. Standard Webhooks: project site standardwebhooks.com
  7. Stripe: Receive Stripe events in your webhook endpoint docs.stripe.com
  8. Twilio: Webhooks connection overrides twilio.com

Keep reading

More on selling under your own name

Start selling under your own brand

  1. Sign up with an email, no call needed1 minute
  2. Get test keys and $5,000 of pretend moneyInstantly
  3. Take order #0001 under your own nameToday