Idempotency
Use a key on calls that send, charge or book, so a retry never does it twice.
Networks fail. A request can time out after Lahuta has already sent the email or booked the time, and your code can't tell whether it went through. If you try again, the customer might get two emails or two bookings.
To prevent that, the calls that send, charge or book take a key in the request body. It's your own name for that one action. Send the same key again and Lahuta recognizes the retry instead of doing the action a second time.
Where keys go
The key is a field in the JSON body, not a header:
{
"key": "wholesale-form:5f2c9a",
"contact": {
"kind": "person",
"firstName": "Maya",
"emails": [{ "label": null, "value": "maya@example.com" }]
},
"subject": "We got your wholesale inquiry",
"body": "Hi Maya,\n\nThanks for reaching out. We'll reply within two days."
}A key is 1 to 128 characters of letters, digits, _, ., : and -. Anything else is refused with a 400.
Which calls take a key
| Call | Key | What a repeat does |
|---|---|---|
POST /v1/emails | Required | Sends nothing. Returns the first email's emailId with "opened": false |
POST /v1/bookings | Required | Books nothing and writes to nobody. Returns the first booking with "opened": false |
POST /v1/checkouts | Required | While the checkout can still be paid, returns the same checkout. Once it's paid or expired, the key is spent and you get a 409 Conflict |
POST /v1/notifications | Required | Notifies nobody twice. The response looks the same as the first time |
PUT /v1/contacts | Optional, as note.key | Writes the note once. Returns the same noteId. Without a key, every call adds another note |
A repeat with the same key returns the result of the first call, even if the rest of the body changed. If you want to send a corrected email, use a new key.
Keys belong to your organization and to one kind of call. Your key on an email never clashes with the same key on a booking, or with another organization's keys.
Calls without a key
These are safe to repeat on their own:
POST /v1/newsletter/subscribe: signing up twice leaves the person subscribed once.PUT /v1/contactswithout a note: contacts are matched on email or phone, so a repeat updates the same contact.PUT /v1/groupsfor person and company groups: the same person sent twice is one row with the latest values. Rows in a general group are added every time, so avoid retrying those blindly.POST /v1/products/checkouts: each call opens a new checkout page, but nothing is charged until the buyer pays. An extra page that nobody opens expires after 24 hours.
Choosing a key
A good key is unique to the action and the same on every retry of it. Build it from something your system already knows:
- A form submission id:
contact-form:8421 - An order or invoice number from your own system:
dues-2026:member-117 - For a booking, the form id plus the chosen time:
studio-booking:8421:2026-10-17T17:00
Don't generate a new random key inside your retry loop. If you need a random key, generate it once, store it with the submission, and reuse it.
export async function sendReceipt(submission) {
// Built from the submission's own id, so every retry sends the same key.
const key = `receipt:${submission.id}`
for (let attempt = 1; attempt <= 3; attempt++) {
const res = await fetch("https://api.lahuta.org/v1/emails", {
method: "POST",
headers: {
"X-Api-Key": process.env.LAHUTA_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ key, ...submission.email }),
})
if (res.ok) return res.json()
if (res.status < 500) throw new Error(`Email refused: ${res.status}`)
}
throw new Error("Email failed after 3 attempts")
}Retrying safely
- Retry with the same key after a timeout, a network error or a
5xx. - Retry with the same key after a
502 CheckoutUnavailable. Nothing was charged, and the same key tries again. - Wait, then retry with the same key after a
429. See Rate limits. - Don't retry a
4xxother than429without changing the request. It will fail the same way.