Resend a bounced message
A message.bounced webhook (or a bounced row in the dashboard) tells you the
receiving server rejected the message, not why. Whether you can simply try
again depends on the bounce type, and Sendoka only suppresses on one of them.
1. Check the bounce type
There is no /v1/messages route — fetch the message on its channel:
GET /v1/emails/{id} for email, GET /v1/sms/{id} for SMS.
curl https://www.sendoka.com/api/v1/emails/msg_01HN... \
-H "Authorization: Bearer $SENDOKA_API_KEY"
On an email bounce, error_message reads Bounce: Permanent (General) — 550 5.1.1 …
or Bounce: Transient (MailboxFull) — …, and provider_response is the SES
bounce notification verbatim — bounceType (Permanent | Transient |
Undetermined), bounceSubType, and bouncedRecipients[].diagnosticCode.
The same transition is in status_history[].
- Transient (mailbox full, greylisting, temporary server issue): nothing was suppressed. Skip to step 3 and send again.
- Permanent (unknown user, domain NXDOMAIN): the recipient is now on the
suppression list with
reason: "bounce", scoped to the sending tenant (org if platform-root). Fix the address before doing anything else — see the gotchas below.
2. Remove the suppression — permanent bounces only, once the address is fixed
Suppressions are addressed by channel + value, not by path segment:
curl -X DELETE "https://www.sendoka.com/api/v1/suppressions?channel=email&value=user@example.com" \
-H "Authorization: Bearer $SENDOKA_API_KEY"
A JSON body { "channel": "email", "value": "user@example.com" } works in place
of the query string.
Only a bounce row is removable. A row the recipient created — a STOP reply
or a spam complaint — answers 409 SUPPRESSION_PROTECTED instead, and this
recipe does not apply: only the recipient opting in again lifts it.
Removal targets the caller's own scope — a tenant-bound key removes the tenant's row, a platform-root key the org-level row.
3. Send again — use a fresh idempotency key
Ask for the body explicitly: ?include=body adds html and text to the
detail response. Both come back null once MESSAGE_CONTENT_TTL_DAYS has
redacted the row, which is not the same as the message having had no HTML part.
import crypto from "node:crypto";
const API = "https://www.sendoka.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.SENDOKA_API_KEY}`,
"Content-Type": "application/json",
};
const original = await fetch(`${API}/emails/msg_01HN...?include=body`, { headers }).then((r) =>
r.json()
);
if (original.html === null && original.text === null) {
throw new Error("Body already redacted — rebuild it from your own records");
}
await fetch(`${API}/emails`, {
method: "POST",
// A NEW key. Replaying the original send's key would return the recorded
// (bounced) response instead of sending anything.
headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({
from: original.from,
to: Array.isArray(original.to) ? original.to : [original.to],
subject: original.subject,
html: original.html,
text: original.text,
}),
});
Gotchas
- Hard bounces (invalid address, domain NXDOMAIN) stay suppressed for a reason. Don't just remove and retry — fix the address first.
- Complaints should never be un-suppressed automatically. That's a legal issue (CAN-SPAM / GDPR). A complaint fires
message.complained, notmessage.bounced. - Sendoka's auto-suppression from bounces is org-wide (+ tenant-scoped in platform mode). Scoping is not per-key.