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
| Method | Endpoint |
|---|---|
| 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
| Header | Value |
|---|---|
Idempotency-Key | Any 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.
// 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
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!"
}
}'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);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
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{
"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
| Code | Meaning | What to do |
|---|---|---|
| 201 | First request — message created | Store the message_id |
201 + Idempotent-Replayed: true | Duplicate — original response replayed | Nothing. The send already happened |
| 400 | Key longer than 255 characters | Shorten the key |
| 409 | A request with this key is still in flight | Wait briefly and retry with the same key |
| 422 | Key already used with a different request body | Use a new key |
409 — still processing
{
"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
{
"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:
POST /messages/sendwith keyorder-4821→ 402 Insufficient credits- You top up your balance
POST /messages/sendwith keyorder-4821again → 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-4821with 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
- Scheduling Messages — queue sends for a future time
- Send Bulk Messages — where idempotency matters most
- Error Handling — full status-code reference and retry strategies