← barua.tz

Webhooks#

Register an https URL and Barua posts each delivery outcome to it as it is recorded. Polling the log every minute is the alternative, and nobody builds it. Up to 20 endpoints per account, each subscribed to the events it wants:

email.deliveredThe receiving server accepted the message for this recipient.
email.bouncedThe receiving server refused it for good. A 5.x.x code also puts the address on the suppression list.
email.deferredThe receiving server refused it for now and Barua's mail server keeps trying. Normal on its own; only a run of them matters.
email.complainedThe recipient reported the message as spam. The address goes on the suppression list.
pingSent by POST /webhooks/{id}/test and by nothing else. Never retried.

Every POST is JSON: { id, type, createdAt, data }. id is the delivery id, repeated in the X-Barua-Delivery header, so a retry you have already handled can be dropped. For email events data carries messageId, recipient, status, dsn, detail, occurredAt and from; a ping's data is { endpointId }. The other headers are X-Barua-Event (the type) and X-Barua-Signature.

{
  "id": "0f1e2d3c-4b5a-4968-8776-655443322110",
  "type": "email.bounced",
  "createdAt": "2026-09-24T08:16:02.000Z",
  "data": {
    "messageId": "1f2e3d4c-5b6a-4798-8a9b-0c1d2e3f4a5b@dukalangu.co.tz",
    "recipient": "juma@example.co.tz",
    "status": "bounced",
    "dsn": "5.1.1",
    "detail": "550 5.1.1 The email account that you tried to reach does not exist",
    "occurredAt": "2026-09-24T08:15:41.000Z",
    "from": "receipts@dukalangu.co.tz"
  }
}

Verifying. The signature header is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 over `${t}.${rawBody}`with the endpoint's secret, which is shown once when the endpoint is created and never again. Sign the raw bytes as received, before any JSON parsing, and compare with a constant-time function: a plain string comparison leaks how many bytes matched.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();

// The signature covers the bytes exactly as sent, so keep the raw body and
// parse it only after the check has passed.
app.post("/hooks/barua", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-Barua-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
  const { t, v1 } = parts;
  if (!t || !v1) return res.status(400).end();

  // Your choice, not a Barua rule: refuse a signature older than five minutes,
  // so a captured request cannot be replayed at you later.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).end();

  const rawBody = req.body.toString("utf8");
  const expected = createHmac("sha256", process.env.BARUA_WEBHOOK_SECRET)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return res.status(401).end();

  const event = JSON.parse(rawBody);
  // event.type is "email.bounced" and so on. event.id is the delivery id, also
  // in X-Barua-Delivery, so a retry you have already handled can be dropped.
  if (event.type === "email.bounced") {
    // mark event.data.recipient as undeliverable in your own records
  }

  // Answer 2xx within 10 seconds. Anything else, or silence, is retried.
  res.status(200).end();
});

app.listen(3000);

Retries. The dispatcher runs once a minute, so the first attempt lands within about a minute of the event. Any 2xx within 10 seconds counts as delivered; the body of your response is never read and redirects are not followed. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours: 6 attempts over about fifteen hours, long enough to fix a deploy. After 10 consecutive failures at one URL the endpoint is switched off (active: false, disabledAt set) and nothing more is posted to it; its pending deliveries wait. A successful test ping switches it back on. Pings are never retried, so testing against a server you are still fixing does not count against the endpoint.

The deliveries list answers why your system did not hear about a bounce: the status your server returned, or why it never answered (timeout, connection_refused, dns_error, tls_error and so on), and when the next try is.

GET /api/v1/webhooks

List webhook endpoints. Scope webhooks:read.

curl
curl https://barua.tz/api/v1/webhooks \
  -H "Authorization: Bearer barua_YOUR_KEY"
response 200
200, no body
400 invalid_requestThe body or query failed validation. The message names the first field that failed.

POST /api/v1/webhooks

Create a webhook endpoint. Scope webhooks:write.

The URL must be https and point at a public host; it is resolved at creation and refused if any address is private. Up to 20 endpoints per account. The response carries the signing secret, and this is the only time it is shown: only ciphertext is stored and no endpoint reads it back. A lost secret means a new endpoint.

curl
curl https://barua.tz/api/v1/webhooks \
  -H "Authorization: Bearer barua_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://shop.dukalangu.co.tz/hooks/barua",
    "events": [
      "email.delivered",
      "email.bounced",
      "email.complained"
    ]
  }'
response 201
201
{
  "id": "e4d3c2b1-a098-4765-b432-10fedcba9876",
  "url": "https://shop.dukalangu.co.tz/hooks/barua",
  "events": [
    "email.delivered",
    "email.bounced",
    "email.complained"
  ],
  "active": true,
  "failureCount": 0,
  "disabledAt": null,
  "createdAt": "2026-09-24T10:20:00.000Z",
  "secret": "whsec_5Yt2Kq8ZbP0mV7xL3nJ9cW1dR4hF6gT8uA2sE5yB7iO"
}
400 invalid_jsonThe request body could not be parsed as JSON.
422 invalid_requestThe body or query failed validation. The message names the first field that failed.
422 invalid_urlThe URL cannot be used. A webhook URL must be https, carry no credentials and point at a public host that resolves. A database URL must be postgresql:// with a host and a user, and may not turn TLS off with sslmode=disable.
422 too_many_endpointsThe account already has 20 webhook endpoints.
503 not_configuredThe server cannot store secrets yet, so neither a webhook secret nor a database URL can be kept.

GET /api/v1/webhooks/{id}

Get a webhook endpoint. Scope webhooks:read.

curl
curl https://barua.tz/api/v1/webhooks/e4d3c2b1-a098-4765-b432-10fedcba9876 \
  -H "Authorization: Bearer barua_YOUR_KEY"
response 200
200
{
  "id": "e4d3c2b1-a098-4765-b432-10fedcba9876",
  "url": "https://shop.dukalangu.co.tz/hooks/barua",
  "events": [
    "email.delivered",
    "email.bounced",
    "email.complained"
  ],
  "active": true,
  "failureCount": 0,
  "disabledAt": null,
  "createdAt": "2026-09-24T10:20:00.000Z"
}
404 webhook_not_foundNo webhook endpoint with that id on this account.

POST /api/v1/webhooks/{id}/test

Post a ping and wait for the answer. Scope webhooks:write.

Posts a ping event now and returns what your server answered, so a signature check can be seen passing or failing here rather than on the first real bounce. A ping that succeeds also switches back on an endpoint that was disabled after 10 consecutive failures. Pings are never retried, and one endpoint accepts one test every 10 seconds.

curl
curl -X POST https://barua.tz/api/v1/webhooks/e4d3c2b1-a098-4765-b432-10fedcba9876/test \
  -H "Authorization: Bearer barua_YOUR_KEY"
response 200
200
{
  "id": "0f1e2d3c-4b5a-4968-8776-655443322110",
  "status": 200,
  "delivered": true,
  "error": null
}
404 webhook_not_foundNo webhook endpoint with that id on this account.
429 ping_cooldownA test ping was sent to this endpoint in the last ten seconds.

GET /api/v1/webhooks/{id}/deliveries

List deliveries to an endpoint. Scope webhooks:read.

What was posted to this endpoint and how it went, newest first: the status your server returned, or the reason it never answered, and when the next try is.

curl
curl "https://barua.tz/api/v1/webhooks/e4d3c2b1-a098-4765-b432-10fedcba9876/deliveries?limit=10" \
  -H "Authorization: Bearer barua_YOUR_KEY"
response 200
200, no body
400 invalid_requestThe body or query failed validation. The message names the first field that failed.
404 webhook_not_foundNo webhook endpoint with that id on this account.

DELETE /api/v1/webhooks/{id}

Delete a webhook endpoint. Scope webhooks:write.

Stops posting to the URL now and deletes every delivery queued or kept for it.

curl
curl -X DELETE https://barua.tz/api/v1/webhooks/e4d3c2b1-a098-4765-b432-10fedcba9876 \
  -H "Authorization: Bearer barua_YOUR_KEY"
response 204
204, no body
404 webhook_not_foundNo webhook endpoint with that id on this account.