Skip to content

Idempotency ​

Retry a send safely. If a request times out, a connection drops, or your job runner fires twice, an idempotency key guarantees the message is created — and charged — exactly once.

Send an Idempotency-Key header with your request. The first request with a given key is processed normally and its response is stored; any later request with the same key replays that stored response instead of sending again.

Retrying without a key double-sends and double-charges

A plain retry of POST /messages/send is a brand new request. It creates a second message and deducts credits a second time. If your integration retries on network errors or timeouts at all, it needs this header.

Supported endpoints ​

MethodEndpoint
POST/api/v1/messages/send
POST/api/v1/messages/bulk

The header is optional — requests without it behave exactly as before, so existing integrations are unaffected.

The header ​

HeaderValue
Idempotency-KeyAny client-generated string, max 255 characters

Choosing a key ​

Use a value that is unique to the intent of the send — one key per message you mean to deliver.

javascript
// Random per attempt-set — the common default.
const key = crypto.randomUUID();

// Or derive it from your own domain object, so even a re-run of the
// whole job reuses the same key and cannot double-send.
const key = `order-confirmation-${orderId}`;

Deriving the key from your own data (invoice-4821, otp-user-99-attempt-3) is the stronger pattern: it survives a process restart, whereas a freshly generated UUID on each run does not.

Do not reuse a key across different messages

Keys are remembered indefinitely (see Retention). Reusing daily-digest every day means every day after the first replays the first day's response and never sends. Include something that varies — a date, an ID, a UUID.

Example ​

bash
curl -X POST https://api.yebolink.com/api/v1/messages/send \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ybk_your_api_key" \
  -H "Idempotency-Key: order-confirmation-4821" \
  -d '{
    "to": "+26878422613",
    "channel": "sms",
    "content": {
      "text": "Your order #4821 is confirmed!"
    }
  }'
javascript
import { randomUUID } from 'crypto';

async function sendWithRetry(payload, { retries = 3 } = {}) {
  // Generate the key ONCE, outside the retry loop — reusing it across
  // attempts is the entire point.
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      const response = await fetch(
        'https://api.yebolink.com/api/v1/messages/send',
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-API-Key': process.env.YEBOLINK_API_KEY,
            'Idempotency-Key': idempotencyKey
          },
          body: JSON.stringify(payload)
        }
      );

      const body = await response.json();

      // The original request is still running — wait and retry the same key.
      if (response.status === 409) {
        await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
        continue;
      }

      if (!response.ok) {
        throw new Error(body.error || body.message);
      }

      if (response.headers.get('Idempotent-Replayed') === 'true') {
        console.log('Replayed the original response — nothing was re-sent.');
      }

      return body.data;
    } catch (error) {
      // Network/timeout failure: we do not know whether the server processed
      // it. Retrying with the SAME key is safe — that is the guarantee.
      if (attempt === retries - 1) throw error;
      await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
    }
  }
}

const result = await sendWithRetry({
  to: '+26878422613',
  channel: 'sms',
  content: { text: 'Your order #4821 is confirmed!' }
});

console.log('Message:', result.message_id);
python
import os
import time
import uuid

import requests


def send_with_retry(payload, retries=3):
    # One key for the whole retry sequence.
    idempotency_key = str(uuid.uuid4())

    for attempt in range(retries):
        try:
            response = requests.post(
                'https://api.yebolink.com/api/v1/messages/send',
                headers={
                    'X-API-Key': os.environ['YEBOLINK_API_KEY'],
                    'Idempotency-Key': idempotency_key,
                },
                json=payload,
                timeout=30,
            )

            # Original still in flight — back off and retry the same key.
            if response.status_code == 409:
                time.sleep(0.5 * (attempt + 1))
                continue

            response.raise_for_status()

            if response.headers.get('Idempotent-Replayed') == 'true':
                print('Replayed the original response — nothing was re-sent.')

            return response.json()['data']

        except requests.exceptions.RequestException:
            # Unknown outcome. Same key makes the retry safe.
            if attempt == retries - 1:
                raise
            time.sleep(0.5 * (attempt + 1))


result = send_with_retry({
    'to': '+26878422613',
    'channel': 'sms',
    'content': {'text': 'Your order #4821 is confirmed!'},
})

print(f"Message: {result['message_id']}")
php
<?php

function sendWithRetry(array $payload, int $retries = 3): array {
    // Key generated once, reused across every attempt.
    $idempotencyKey = bin2hex(random_bytes(16));

    for ($attempt = 0; $attempt < $retries; $attempt++) {
        $curl = curl_init();
        curl_setopt_array($curl, [
            CURLOPT_URL => 'https://api.yebolink.com/api/v1/messages/send',
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'X-API-Key: ' . getenv('YEBOLINK_API_KEY'),
                'Idempotency-Key: ' . $idempotencyKey,
            ],
            CURLOPT_POSTFIELDS => json_encode($payload),
        ]);

        $response = curl_exec($curl);
        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);

        // Original request still running — wait, then retry the same key.
        if ($httpCode === 409) {
            usleep(500000 * ($attempt + 1));
            continue;
        }

        if ($httpCode === 201) {
            return json_decode($response, true)['data'];
        }

        throw new Exception('Send failed: ' . $response);
    }

    throw new Exception('Max retries exceeded');
}

$result = sendWithRetry([
    'to' => '+26878422613',
    'channel' => 'sms',
    'content' => ['text' => 'Your order #4821 is confirmed!'],
]);

echo "Message: {$result['message_id']}\n";
?>

What a duplicate returns ​

A replayed request returns the original response, verbatim — the same status code and the same body, including the same message_id. Nothing is sent again and no credits are charged.

Replays carry an extra response header so you can tell them apart:

Idempotent-Replayed: true
json
{
  "success": true,
  "data": {
    "message_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "queued",
    "credits_used": "1.00",
    "scheduled_at": null,
    "created_at": "2026-08-28T12:00:00.000Z"
  }
}

The body is a snapshot, not live state

The replayed body is exactly what was returned first time — so status will still read queued even if the message has since been delivered. Treat it as proof the send was accepted, and use GET /api/v1/messages/:id for current delivery state.

Response reference ​

CodeMeaningWhat to do
201First request — message createdStore the message_id
201 + Idempotent-Replayed: trueDuplicate — original response replayedNothing. The send already happened
400Key longer than 255 charactersShorten the key
409A request with this key is still in flightWait briefly and retry with the same key
422Key already used with a different request bodyUse a new key

409 — still processing ​

json
{
  "success": false,
  "error": "A request with this Idempotency-Key is already being processed. Retry shortly."
}

Two requests with the same key arrived at once and the first has not finished. This is a transient state: back off briefly and retry with the same key. Once the original completes you will get its response as a replay.

422 — key reuse mismatch ​

json
{
  "success": false,
  "error": "This Idempotency-Key was already used with a different request. Use a new key."
}

Each key is bound to a fingerprint of the request that first claimed it (method, path, and body). If you resend the same key with a different recipient or different content, YeboLink refuses rather than returning a response that does not match what you asked for.

Retries must resend the byte-identical body

Because the fingerprint covers the request body, a retry that changes anything — a regenerated timestamp inside metadata, a reordered recipient list — is treated as a different request and rejected with 422. Build the payload once, then reuse that exact object for every attempt.

Failed requests release the key ​

Only a successful (2xx) response is stored for replay. If the first attempt fails — a 400 validation error, a 402 for insufficient credits, a 500 — the key is released, and you are free to retry with the same key once the cause is fixed.

So this sequence works exactly as you would hope:

  1. POST /messages/send with key order-4821 → 402 Insufficient credits
  2. You top up your balance
  3. POST /messages/send with key order-4821 again → 201 Created, message sent

The key is also never consumed by requests that are rejected before the send is attempted — validation errors and rate limits are checked first, so a 400 or a 429 never burns a key.

If the server crashes mid-request and never records an outcome, the key is automatically reclaimed after 60 seconds, so a stalled attempt cannot lock a key permanently.

Retention ​

Keys are stored per workspace and are retained indefinitely — there is no expiry window after which a completed key becomes reusable.

Two consequences worth designing around:

  • A key is deduplicated forever. Reusing one months later still replays the original response instead of sending. Always include something that varies.
  • Keys never collide across workspaces. Two workspaces can each use order-4821 with no interference.

Best practices ​

Generate the key before the first attempt ​

The key must be created outside your retry loop. A key regenerated per attempt provides no protection at all — each attempt looks like a brand-new request.

Derive keys from your own records ​

invoice-4821 or otp-user-99-2026-08-28 beats a random UUID when the whole job might be re-run: a replayed job reuses the same key and cannot double-send. A UUID only protects within a single process lifetime.

Always use a key on bulk sends ​

POST /messages/bulk charges for every recipient. A duplicated 500-recipient batch is 500 wasted credits and 500 annoyed customers — by far the most expensive request to get wrong.

Treat 409 as "wait", not "fail" ​

A 409 means your own earlier request is still working. Retrying the same key after a short backoff is correct; switching to a new key would defeat the protection and send twice.

Next Steps ​

Built with VitePress