HelipodDocs

Webhooks

Receive delivery, engagement, failure and inbound-mail events on your own HTTPS endpoint.

Register an HTTPS endpoint and Helipod Email will POST a signed JSON event to it when something happens to your project's mail: delivered, bounced, opened, received and more.

All endpoints on this page accept an email API key (or a Helipod API key with the Email API enabled) and act on the key's project.

Events

EventWhen
email.sentA message was handed over for delivery.
email.deliveredThe recipient's mail server accepted it.
email.bouncedThe recipient's server refused it.
email.complainedThe recipient marked it as spam.
email.openedThe recipient opened it.
email.clickedThe recipient clicked a link.
email.rejectedIt was rejected at delivery time (for example a content problem).
email.failedSending gave up after retries. A broadcast sends one aggregated email.failed per run, listing up to 100 failed recipients.
email.receivedA message arrived at one of your receiving addresses. See Inbox.

Payload

Every delivery is a POST with Content-Type: application/json:

{
  "type": "email.delivered",
  "createdAt": "2026-10-16T09:14:10.004Z",
  "data": {
    "messageId": "01a9f3c2b7d4e5f6a8b9c0d1e2f3a4b5",
    "recipients": ["[email protected]"],
    "notification": { }
  }
}

Prop

Type

What data.notification contains depends on the event:

  • email.sent, email.delivered, email.bounced, email.complained, email.opened, email.clicked, email.rejected: a summary of the delivery event. Fields appear only for the matching event:
{
  "type": "bounce",
  "mail": { "messageId": "0100019a…", "timestamp": "2026-10-16T10:02:11.000Z", "subject": "Your receipt", "destination": ["[email protected]"] },
  "bounce": { "bounceType": "Permanent", "bounceSubType": "General", "timestamp": "…", "recipients": [{ "emailAddress": "[email protected]", "status": "5.1.1", "action": "failed", "diagnosticCode": "smtp; 550 5.1.1 user unknown" }] }
}

Other events carry delivery (timestamp, processingTimeMillis, recipients), complaint (complaintFeedbackType, recipients), open and click (timestamp, userAgent, link for clicks), reject (reason) or deliveryDelay.

  • email.failed for a single send: { "error": "...", "jobId": "..." }.
  • email.failed for a broadcast: { "broadcastId": "...", "failed": 3, "sent": 1237, "error": "send_error" }.
  • email.received: a summary of the received message:
{
  "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
  "recipient": "[email protected]",
  "deliveredTo": ["[email protected]"],
  "mailbox": "support",
  "from": "[email protected]",
  "fromName": "Ana Putri",
  "to": ["[email protected]"],
  "cc": [],
  "subject": "Question about my order",
  "snippet": "Hi, I ordered on Monday and ...",
  "textBody": "Hi, I ordered on Monday and ...",
  "hasAttachments": true,
  "attachments": [{ "index": 0, "filename": "receipt.pdf", "contentType": "application/pdf", "size": 48213 }],
  "folder": "inbox",
  "spamVerdict": "PASS",
  "virusVerdict": "PASS",
  "receivedAt": "2026-10-16T10:02:11.000Z"
}

textBody is cut at 64 KB. Use id with GET /inbound/messages/{id} for the full message and attachments.

Verify the signature

Every request carries a Helipod-Signature header:

Helipod-Signature: t=1760605450,v1=5f2b0c7e9a1d...
  • t is the Unix time (seconds) the event was signed.
  • v1 is the hex HMAC-SHA256 of the string <t>.<raw request body>, keyed with your webhook secret.

Compute the HMAC over the raw body bytes, before any JSON parsing, compare it in constant time, and reject old timestamps to block replays.

import crypto from "node:crypto";

export function verifyHelipodSignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Delivery and retries

  • Respond with any 2xx within 8 seconds.
  • A network error, a timeout or a 5xx response is retried up to 3 attempts in total, 1 and then 2 seconds apart. Retries carry the same body and signature.
  • A 4xx response is not retried.
  • Events can arrive more than once and out of order. Make your handler idempotent (for example key on type + messageId + recipient).
  • The endpoint's host is resolved before each delivery and must be a public address; deliveries to private or internal addresses are refused.
  • failCount on the webhook counts consecutive failed deliveries and resets on the next success.

List webhooks

GET/webhooks
All webhooks of the project.
curl https://mail.helipod.io/api/email/webhooks \
  -H "Authorization: Bearer your_email_api_key"
{
  "success": true,
  "data": [
    {
      "id": "8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e",
      "url": "https://hooks.example.com/helipod-email",
      "events": ["email.delivered", "email.bounced", "email.complained"],
      "enabled": true,
      "failCount": 0,
      "lastTriggeredAt": "2026-10-16T09:14:10.004Z",
      "createdAt": "2026-10-01T08:00:00.000Z"
    }
  ]
}

Create a webhook

POST/webhooks
Register an endpoint. The signing secret is returned once.

Prop

Type

curl -X POST https://mail.helipod.io/api/email/webhooks \
  -H "Authorization: Bearer your_email_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/helipod-email",
    "events": ["email.delivered", "email.bounced", "email.complained", "email.received"]
  }'

Response 201:

{
  "success": true,
  "data": {
    "id": "8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e",
    "url": "https://hooks.example.com/helipod-email",
    "events": ["email.delivered", "email.bounced", "email.complained", "email.received"],
    "enabled": true,
    "failCount": 0,
    "lastTriggeredAt": null,
    "createdAt": "2026-10-16T09:30:00.000Z",
    "secret": "whsec_3f9a1c2b7d4e5f6a8b9c0d1e2f3a4b5c"
  }
}

Store secret now: it is not returned again. Errors: 400 INVALID_URL, 400 NO_EVENTS, 402 WEBHOOK_LIMIT_REACHED.

There is no update endpoint. To change the URL or events, create a new webhook and delete the old one.

Delete a webhook

DELETE/webhooks/{id}
Stop sending events to an endpoint.
curl -X DELETE https://mail.helipod.io/api/email/webhooks/8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e \
  -H "Authorization: Bearer your_email_api_key"
{ "success": true, "data": { "deleted": true } }

Returns 404 WEBHOOK_NOT_FOUND for an unknown id.

On this page