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, not message.bounced.
  • Sendoka's auto-suppression from bounces is org-wide (+ tenant-scoped in platform mode). Scoping is not per-key.