# 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**

```bash
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 <hello@shop.vn>",
    "to": "customer@example.com",
    "subject": "Order #1001 confirmed",
    "html": "<p>Thanks for your order!</p>"
  }'
```

**Node.js**

```js
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 <hello@shop.vn>", to: "customer@example.com", 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" });
```

**Python**

```python
from hatang import Hatang

hatang = Hatang()  # reads HATANG_API_KEY
hatang.emails.send(
    {
        "from": "Shop <hello@shop.vn>",
        "to": "customer@example.com",
        "subject": "Order #1001 confirmed",
        "html": "<p>Thanks for your order!</p>",
    },
    idempotency_key="order-1001-confirmation",
)
```

**PHP**

```php
$res = $client->post('/emails', [
    'headers' => ['Idempotency-Key' => 'order-1001-confirmation'],
    'json' => [
        'from' => 'Shop <hello@shop.vn>',
        'to' => 'customer@example.com',
        '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 the `client_id` they 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. |

```json
{
  "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

1. **Create the key before the first attempt** and keep it with the job, so a crash between attempts does not lose it.
2. **Retry only what is temporary:** network errors, `429 rate_limit_exceeded` and `5xx`. Use exponential backoff and honor `retry-after` ([Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md)).
3. **Do not retry `4xx` errors** other than `429` and `409 concurrent_idempotent_requests`; fix the request instead.
4. **Treat a replay as success.** A `200` for a repeated email or a `"duplicate": true` chat message means the first attempt worked.
5. **Make your webhook handlers idempotent too.** Deliveries can repeat; see [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md#make-your-handler-idempotent).

## Next steps

- [Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md)
- [Errors](https://hatang.dev/docs/guides/getting-started/errors.md)
- [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md)

Source: https://hatang.dev/docs/guides/getting-started/idempotency
