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/texts | Required | Sends nothing. Returns the first text's messageId 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 |
POST /v1/contacts/{contactId}/notes | Optional | Writes the note once and returns it. Shares keys with note.key above. Without a key, every call adds another note |
PUT /v1/groups | Optional, as rows[].key on a general group | Writes over the row with that key instead of adding one. Without a key, every call adds another row |
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 on phone when there's no email), 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 unless they carry akey, so give them one before you retry.PATCH /v1/contacts/{contactId}andPATCH /v1/reminders/{reminderId}: sending the same change twice leaves the same result.POST /v1/contacts/archive,POST /v1/groups/{group}/rows/removeandDELETE /v1/reminders/{reminderId}: what's already archived, removed or deleted is skipped, so a retry succeeds and changes nothing.POST /v1/workflows/{workflowId}/start: a contact who's already going through the workflow isn't started again. Once their run ends, a repeat starts them again.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.
Related
Rate limits
The hourly limits on calls that send email or texts, start workflows, open checkouts or book times, and how to handle hitting one.
Contacts and groups
Save the people who fill in your site's forms as contacts, look a contact up by email or phone, add them to groups with extra details, and read a group back onto your site.