Idempotency
A network error or a timeout leaves you not knowing whether a request went through. If you simply send it again, a customer may get the same email twice or be asked to pay twice. An idempotency key makes the retry safe: Ha Tang does the work once and answers every repeat with the same result.
Where it is supported
Send the key in the Idempotency-Key header. It is any string you choose, up to 256 characters.
| Endpoint | Scope of the key | A repeat with the same body | A repeat with a different body |
|---|---|---|---|
POST /emails | The business | 200 with the same id; nothing is sent again | 400 invalid_idempotent_request |
POST /payments | The business and the Payments environment | 201 with the same payment | 400 invalid_idempotent_request |
POST /chat/conversations/{id}/messages | The conversation | 200 with the same message and "duplicate": true | 200 with the first message (the body is not compared) |
POST /emails/batch and other endpoints do not take an idempotency key.
Send an idempotency key
Use a key that identifies the business operation, not the HTTP attempt: your order id, welcome-<user id>, invoice-<number>-reminder-1. Generate it once, store it with the job, and reuse it on every retry.
- cURL
- Node.js
- Python
- PHP
curl -X POST https://api.hatang.dev/emails \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001-confirmation" \
-d '{
"from": "Shop <[email protected]>",
"to": "[email protected]",
"subject": "Order #1001 confirmed",
"html": "<p>Thanks for your order!</p>"
}'
import { Hatang } from "@hatang/sdk";
const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" });
// Email
await hatang.emails.send(
{ from: "Shop <[email protected]>", to: "[email protected]", subject: "Order #1001 confirmed", html: "<p>Thanks!</p>" },
{ idempotencyKey: "order-1001-confirmation" },
);
// Payment
await hatang.payments.create({ amount: 150000, description: "Order 1001" }, { idempotencyKey: "order-1001" });
// Chat reply
await hatang.chat.conversations.send("cnv_123...", { content: "Your order ships today." }, { idempotencyKey: "order-1001-shipped" });
from hatang import Hatang
hatang = Hatang() # reads HATANG_API_KEY
hatang.emails.send(
{
"subject": "Order #1001 confirmed",
"html": "<p>Thanks for your order!</p>",
},
idempotency_key="order-1001-confirmation",
)
$res = $client->post('/emails', [
'headers' => ['Idempotency-Key' => 'order-1001-confirmation'],
'json' => [
'subject' => 'Order #1001 confirmed',
'html' => '<p>Thanks for your order!</p>',
],
]);
How keys are stored
- Email and payments. The key is stored with the email or payment it created, together with a hash of the request body. A repeat with the same key is compared against that hash. Keys do not expire: as long as the email or payment exists, its key keeps pointing to it. A replayed email does not count against the daily quota again.
- Chat messages. The key becomes the message's
client_id. A repeat in the same conversation returns the existing message with"duplicate": true. The widget and the mobile SDKs do the same thing with theclient_idthey send on each message, which is why a message sent twice over a flaky connection appears once in the inbox. - Payments environments. A payment key is scoped to its environment, so the same order id can be used once in development and once in production.
Conflicts
name | Status | Cause | What to do |
|---|---|---|---|
invalid_idempotent_request | 400 | The key was already used with a different request body | You reused a key for a different operation. Generate a new key; never change the body of a retry. |
concurrent_idempotent_requests | 409 | Email only: two requests with the same key arrived at the same moment | Wait a moment and retry with the same key; you get the result of the first one. |
{
"statusCode": 400,
"name": "invalid_idempotent_request",
"message": "Same idempotency key used with a different request payload"
}
"Different body" means any changed value, such as a new timestamp or a regenerated random id inside the payload. Build the payload once, store it with the key, and resend the same payload on every retry.
Retry advice
- Create the key before the first attempt and keep it with the job, so a crash between attempts does not lose it.
- Retry only what is temporary: network errors,
429 rate_limit_exceededand5xx. Use exponential backoff and honorretry-after(Rate limits). - Do not retry
4xxerrors other than429and409 concurrent_idempotent_requests; fix the request instead. - Treat a replay as success. A
200for a repeated email or a"duplicate": truechat message means the first attempt worked. - Make your webhook handlers idempotent too. Deliveries can repeat; see Webhooks.