# Idempotency

> How do I retry a POST safely without being charged twice?

Source: https://docs.betterjobs.cc/platform/idempotency/

Networks fail. A request can time out after BetterJobs already did the work. Send an `Idempotency-Key` header on every `POST`, and reuse it when you retry. The retry returns the first response instead of doing the work again.

## How it works

| You send                          | You get                                                                         |
| --------------------------------- | ------------------------------------------------------------------------------- |
| A new key                         | The request runs normally.                                                      |
| The same key and the same body    | The **first** response, replayed. Nothing runs again. Nothing is charged again. |
| The same key and a different body | `409 idempotency_conflict`. Nothing runs.                                       |

The key is any string up to 255 characters. A UUID v4 is the simplest choice.

## Which requests take it

| Endpoint                   | Without a key, a retry could…                                                                      |
| -------------------------- | -------------------------------------------------------------------------------------------------- |
| `POST /v1/jobs/search`     | Run the search again. Jobs you already paid for are free, so the main cost is time and rate limit. |
| `POST /v1/searches`        | Start a **second** async search.                                                                   |
| `POST /v1/watches`         | Create a **second** watch, which then delivers every event twice.                                  |
| `POST /v1/webhooks/replay` | Queue the same event twice.                                                                        |

`GET` and `DELETE` are already safe to repeat. They take no key. If a retried `DELETE /v1/watches/{id}` returns `404 not_found`, the first attempt worked.

> You are never charged twice for a job anyway
>
> BetterJobs bills each unique job once. A repeated search returns jobs you already paid for at no cost, and `GET /v1/billing/ledger` shows them with `reason: already_paid`. The key protects you from the other side effects: duplicate async searches, duplicate watches, wasted time. See [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md).

## Generate once, reuse on retry

Create the key **before** the first attempt, outside your retry loop. A key generated inside the loop is a new key on every attempt and protects nothing.

**curl**

```bash
# Pick one key per logical request. Reuse it verbatim on retry.
curl https://api.betterjobs.cc/v1/watches \
  --retry 5 \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01" \
  -H "Idempotency-Key: 7b8f2c4e-1a3d-4f5b-9c6e-0d2a4b6c8e1f" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "company",
    "domain": "acme-robotics.example",
    "webhook_url": "https://hooks.northwind.example/betterjobs",
    "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"]
  }'
```

**Python**

```python
import os
import uuid


API = "https://api.betterjobs.cc/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
    "BetterJobs-Version": "2026-10-01",
}


key = str(uuid.uuid4())  # once, before any attempt
watch = send(  # send() retries 429/5xx with the same headers: see Rate limits
    "POST",
    f"{API}/watches",
    headers={**HEADERS, "Idempotency-Key": key},
    json={
        "type": "company",
        "domain": "acme-robotics.example",
        "webhook_url": "https://hooks.northwind.example/betterjobs",
        "events": ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"],
    },
).json()
```

**TypeScript**

```ts
const API = "https://api.betterjobs.cc/v1";


const key = crypto.randomUUID(); // once, before any attempt
// send() retries 429/5xx with the same init: see Rate limits
const res = await send(`${API}/watches`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
    "BetterJobs-Version": "2026-10-01",
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify({
    type: "company",
    domain: "acme-robotics.example",
    webhook_url: "https://hooks.northwind.example/betterjobs",
    events: ["job.opened", "job.closed", "company.hiring_started", "company.hiring_stopped"],
  }),
});
const watch = await res.json();
```

`send()` is the retry helper from [Rate limits](https://docs.betterjobs.cc/platform/rate-limits.md#retry-with-backoff).

## Choosing keys

- **One key per logical operation.** “Create the watch for acme-robotics.example” is one operation, however many attempts it takes.
- **Derive it when the operation already has an id.** If a job in your own queue triggers the request, a key such as `watch-acme-robotics.example-<your job id>` survives a process restart. A random key held in memory does not.
- **New body, new key.** Each page of a paged search has a different `cursor`, so each page needs its own key. Reusing a key with a changed body returns `409 idempotency_conflict`.
- **No secrets in keys.** Keys can appear in logs. Do not put API keys, emails or other personal data in them.

> 409 idempotency\_conflict means a bug in your key logic
>
> You sent a key you had already used, with a different body. Do not retry with the same key. Find out why two different requests got the same key, then send the new request with a new key. See [`idempotency_conflict`](https://docs.betterjobs.cc/platform/errors.md#idempotency_conflict).

## Related

- [Rate limits](https://docs.betterjobs.cc/platform/rate-limits.md)
- [Errors](https://docs.betterjobs.cc/platform/errors.md#should-i-retry)
- [Async searches](https://docs.betterjobs.cc/platform/async-searches.md)
