Concepts
Idempotency
Safe retries with Idempotency-Key, replays, unknown outcomes and how to reconcile them.
Networks fail after a request was sent and before its answer arrived. Retrying a write blindly could then create a second lead, start a second discovery run, or run the same import twice. An Idempotency-Key makes the retry safe: the API runs the request once and answers every retry with the same result.
curl -X POST https://api.dmhero.ai/v1/leads \
-H "Authorization: Bearer $DMHERO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c9a2e-3b4d-4c5e-8f9a-0b1c2d3e4f5a" \
-d '{ "ig_handle": "jane.doe" }'The rules
- Generate one key per logical action, a UUID v4 is ideal, and reuse it for every retry of that action. A new action gets a new key.
- Keys are 1 to 255 printable ASCII characters. Keys starting with
mcp:are reserved for the MCP server. - A key lives 24 hours. After that the same key starts a new request.
- A key belongs to the API key that used it and to that key's rotations: a retry after you rotated the key still deduplicates, while another key of the same workspace can never replay your result.
- A retry must be the same request: same operation, path and body, from an API key with the same reps (a rotated key keeps them). The same Idempotency-Key with a different request answers 422
API_IDEMPOTENCY_MISMATCH_422. - GET and DELETE need no key: they are naturally safe to repeat (a second DELETE answers 404).
What a retry gets back
| Situation | Answer |
|---|---|
| The first request finished | Its result again, with the header DMHero-Idempotent-Replay: true. The resource is read fresh, so a lead deleted since answers 404. |
| The first request finished with a 4xx | The same error again. |
| The first request is still running | 409 API_IDEMPOTENCY_IN_PROGRESS_409. Wait a moment and retry with the same key. |
| The first request failed on our side (5xx) | For most operations the key is released and the retry runs the request. |
| Same key, different request | 422 API_IDEMPOTENCY_MISMATCH_422. |
Validation, authentication and rate limit refusals happen before the key is recorded, so a malformed request never uses up a key.
When the outcome is unknown
A few operations act on the world outside DM Hero, where a half finished attempt cannot be undone: reserving discovery tokens, starting a bulk change, an import or an export. If our server stopped after acting and before recording the result, we cannot know whether it acted. A retry with the same key then answers 409 API_IDEMPOTENCY_OUTCOME_UNKNOWN_409 with claimed_at, the time the first attempt started, and does not act again.
Reconcile, then decide:
- A discovery run: list
GET /v1/discovery/runs?created_after=withclaimed_at. - A job (import, export, bulk change): list
GET /v1/jobs.
If nothing happened, send the request again with a new key. A new key is your explicit decision to try again; the API never makes it for you.
Which operations take a key
Required operations refuse a request without a key (400 API_IDEMPOTENCY_KEY_REQUIRED_400). Accepted means optional but recommended for anything you retry automatically.
| Operation | Method and path | Idempotency-Key |
|---|---|---|
| conversations.drafts.approve | POST /v1/conversations/{conversation_id}/drafts/{draft_id}/approve | Required |
| discovery.runs.create | POST /v1/discovery/runs | Required |
| leads.bulk.confirm | POST /v1/leads/bulk-actions/{job_id}/confirm | Required |
| leads.exports.create | POST /v1/leads/exports | Required |
| leads.imports.create | POST /v1/leads/imports | Required |
| campaign_leads.upsert_enroll | POST /v1/campaign-leads | Accepted |
| campaigns.archive | POST /v1/campaigns/{campaign_id}/archive | Accepted |
| campaigns.clone | POST /v1/campaigns/{campaign_id}/clone | Accepted |
| campaigns.create | POST /v1/campaigns | Accepted |
| campaigns.pause | POST /v1/campaigns/{campaign_id}/pause | Accepted |
| campaigns.resume | POST /v1/campaigns/{campaign_id}/resume | Accepted |
| campaigns.start | POST /v1/campaigns/{campaign_id}/start | Accepted |
| conversations.archive | POST /v1/conversations/{conversation_id}/archive | Accepted |
| conversations.drafts.dismiss | POST /v1/conversations/{conversation_id}/drafts/{draft_id}/dismiss | Accepted |
| conversations.mark_read | POST /v1/conversations/{conversation_id}/read | Accepted |
| conversations.setter.hand | POST /v1/conversations/{conversation_id}/setter/hand | Accepted |
| conversations.setter.resume | POST /v1/conversations/{conversation_id}/setter/resume | Accepted |
| conversations.star | POST /v1/conversations/{conversation_id}/star | Accepted |
| conversations.unarchive | POST /v1/conversations/{conversation_id}/unarchive | Accepted |
| conversations.unstar | POST /v1/conversations/{conversation_id}/unstar | Accepted |
| custom_fields.create | POST /v1/custom-fields | Accepted |
| discovery.candidates.promote | POST /v1/discovery/runs/{run_id}/promote | Accepted |
| discovery.handles.resolve | POST /v1/discovery/handles/resolve | Accepted |
| discovery.recipes.create | POST /v1/discovery/recipes | Accepted |
| discovery.runs.cancel | POST /v1/discovery/runs/{run_id}/cancel | Accepted |
| discovery.runs.preview_decision | POST /v1/discovery/runs/{run_id}/preview-decision | Accepted |
| enrollments.create | POST /v1/campaigns/{campaign_id}/enrollments | Accepted |
| enrollments.pause | POST /v1/enrollments/{enrollment_id}/pause | Accepted |
| enrollments.resume | POST /v1/enrollments/{enrollment_id}/resume | Accepted |
| folders.create | POST /v1/folders | Accepted |
| jobs.cancel | POST /v1/jobs/{job_id}/cancel | Accepted |
| leads.bulk.preview | POST /v1/leads/bulk-actions | Accepted |
| leads.tags.add | POST /v1/leads/{lead_id}/tags | Accepted |
| leads.upsert | POST /v1/leads | Accepted |
| reps.excluded.add | POST /v1/reps/{rep_id}/excluded-handles | Accepted |
| setter.knowledge.create | POST /v1/reps/{rep_id}/setter/knowledge | Accepted |
| setter.variants.archive | POST /v1/setter/variants/{variant_id}/archive | Accepted |
| setter.variants.create | POST /v1/reps/{rep_id}/setter/variants | Accepted |
| setter.variants.promote | POST /v1/setter/variants/{variant_id}/promote | Accepted |
| setter.variants.publish | POST /v1/setter/variants/{variant_id}/publish | Accepted |
| setter.variants.start_test | POST /v1/setter/variants/{variant_id}/start-test | Accepted |
| suppressions.add | POST /v1/suppressions | Accepted |
| templates.create | POST /v1/templates | Accepted |
| templates.spintax.propose | POST /v1/templates/spintax-proposals | Accepted |
| webhooks.deliveries.redeliver | POST /v1/webhook-deliveries/{delivery_id}/redeliver | Accepted |
| webhooks.endpoints.create | POST /v1/webhook-endpoints | Accepted |
| webhooks.endpoints.rotate_secret | POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret | Accepted |
| webhooks.endpoints.test | POST /v1/webhook-endpoints/{endpoint_id}/test | Accepted |