Taifa MailTaifa Mail Docs
API Reference

Webhook Events Reference

Complete reference of all webhook event types, payload formats, and signature verification for Taifa Mail.

Base URL: https://govconnect.ke/v1

This page documents the webhook management API, event type schemas, delivery inspection, and signature verification.

Live outbound webhook delivery is not enabled yet. Creating a webhook stores your URL and event subscriptions, but Taifa Mail does not automatically POST email.sent, email.delivered, email.opened, and similar lifecycle events today. Deliveries you will see now:

  • Test deliveries from POST /v1/webhooks/{id}/test (sample email.delivered).
  • email.inbound.forwarded when an inbound forwarding rule targets an HTTPS URL.

Track real outbound events in Developer → Email Logs and over GET /v1/emails until live fan-out ships.

All management endpoints require authentication via API Key or JWT cookie. API keys use the tfm_k_ prefix.

Manage webhooks with an official SDK: the webhooks resource creates, lists, updates, tests, and reads deliveries. Guides: TypeScript, Python, Go, PHP, Ruby, Rust, Java, .NET, Swift.


Manage webhooks

List webhooks

GET /v1/webhooks/

Returns your active webhook endpoints as a JSON array.

curl https://govconnect.ke/v1/webhooks/ \
  -H "Authorization: Bearer tfm_k_YOUR_API_KEY"

Response:

[
  {
    "id": "8f1d2c3a-...",
    "url": "https://yourapp.com/webhooks/taifamail",
    "events": ["email.delivered", "email.bounced"],
    "secret": "whsec_...",
    "is_active": true,
    "created_at": "2026-04-06T10:00:00Z"
  }
]

Create a webhook

POST /v1/webhooks/

Request body:

FieldTypeRequiredDescription
urlstringYesThe HTTPS endpoint to deliver events to.
eventsstring[]YesEvent types to subscribe to (see Event types).

The signing secret is generated by the server and returned in the response. There is no secret field in the request body.

curl -X POST https://govconnect.ke/v1/webhooks/ \
  -H "Authorization: Bearer tfm_k_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/taifamail",
    "events": ["email.delivered"]
  }'

Returns 201 Created with the webhook object, including its secret.

Update a webhook

PATCH /v1/webhooks/{webhook_id}

Request body (all fields optional):

FieldTypeDescription
urlstringNew delivery endpoint.
eventsstring[]New event subscription list.
is_activebooleanSet false to pause delivery, true to resume.

Delete a webhook

DELETE /v1/webhooks/{webhook_id}

Returns 204 No Content.

Send a test event

POST /v1/webhooks/{webhook_id}/test

Queues a sample email.delivered event to the endpoint so you can verify your integration. The test delivery is logged like any other.

{ "queued": true, "url": "https://yourapp.com/webhooks/taifamail" }

List delivery attempts

GET /v1/webhooks/{webhook_id}/deliveries

Query parameters:

ParameterTypeDefaultDescription
pageinteger0Page number (zero-indexed).
limitinteger20Results per page (1-100).
statusstring--Filter by delivery status.

Response:

{
  "items": [
    {
      "id": "...",
      "webhook_id": "...",
      "event_type": "email.delivered",
      "status": "delivered",
      "response_status": 200,
      "attempt": 1,
      "next_retry_at": null,
      "created_at": "2026-04-06T10:00:03Z"
    }
  ],
  "total": 1,
  "page": 0,
  "limit": 20
}

Get a delivery attempt

GET /v1/webhooks/{webhook_id}/deliveries/{delivery_id}
GET /v1/webhook-deliveries/{delivery_id}

Returns the full delivery record, including the request payload, response_body, and endpoint_url. The flat /v1/webhook-deliveries/{delivery_id} form looks the delivery up by ID alone.


Common payload fields

Every webhook payload includes these fields:

FieldTypeDescription
eventstringThe event type (e.g. email.delivered).
email_idstringThe ID of the email that triggered the event.
recipientstringThe recipient email address.
timestampstring (ISO 8601)When the event occurred.
metadataobjectAdditional context (tags, domain, subject).

Event types

Webhook payloads use email.* event types (for example email.opened). The email events API on GET /v1/emails/{id}/events uses shorter names (open, click). The schemas below describe webhook payloads when live delivery is enabled.

email.sent

The email has been handed off to the recipient's mail server.

{
  "event": "email.sent",
  "email_id": "em_abc123",
  "recipient": "jane@example.com",
  "timestamp": "2026-04-06T10:00:01Z",
  "metadata": {
    "subject": "Service request received",
    "tag": "transactional",
    "domain": "notifications.yourapp.com"
  }
}

email.delivered

The recipient's mail server confirmed delivery.

{
  "event": "email.delivered",
  "email_id": "em_abc123",
  "recipient": "jane@example.com",
  "timestamp": "2026-04-06T10:00:03Z",
  "metadata": {
    "subject": "Service request received",
    "tag": "transactional",
    "domain": "notifications.yourapp.com",
    "smtp_response": "250 2.0.0 OK"
  }
}

email.opened

The recipient opened the email (tracking pixel loaded).

{
  "event": "email.opened",
  "email_id": "em_abc123",
  "recipient": "jane@example.com",
  "timestamp": "2026-04-06T10:05:30Z",
  "metadata": {
    "subject": "Service request received",
    "tag": "transactional",
    "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
    "ip": "41.90.xx.xx"
  }
}

email.clicked

The recipient clicked a tracked link.

{
  "event": "email.clicked",
  "email_id": "em_abc123",
  "recipient": "jane@example.com",
  "timestamp": "2026-04-06T10:06:12Z",
  "metadata": {
    "subject": "Service request received",
    "tag": "transactional",
    "url": "https://yourapp.com/services/SR-12345",
    "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
    "ip": "41.90.xx.xx"
  }
}

email.bounced

The email was permanently rejected by the recipient's mail server.

{
  "event": "email.bounced",
  "email_id": "em_abc123",
  "recipient": "invalid@example.com",
  "timestamp": "2026-04-06T10:00:02Z",
  "metadata": {
    "subject": "Service request received",
    "tag": "transactional",
    "dsn": "5.1.1",
    "response": "550 5.1.1 The email account that you tried to reach does not exist",
    "bounce_type": "hard"
  }
}

email.failed

Delivery failed due to an internal error or configuration issue.

{
  "event": "email.failed",
  "email_id": "em_abc123",
  "recipient": "jane@example.com",
  "timestamp": "2026-04-06T10:00:01Z",
  "metadata": {
    "subject": "Service request received",
    "tag": "transactional",
    "error": "Domain 'unverified.com' is not verified"
  }
}

email.complained

The recipient marked the email as spam (received via feedback loop).

{
  "event": "email.complained",
  "email_id": "em_abc123",
  "recipient": "jane@example.com",
  "timestamp": "2026-04-06T14:30:00Z",
  "metadata": {
    "subject": "Service request received",
    "tag": "transactional",
    "feedback_type": "abuse"
  }
}

email.unsubscribed

The recipient clicked the List-Unsubscribe link or used one-click unsubscribe.

{
  "event": "email.unsubscribed",
  "email_id": "em_abc123",
  "recipient": "jane@example.com",
  "timestamp": "2026-04-06T11:00:00Z",
  "metadata": {
    "subject": "Service request received",
    "tag": "transactional",
    "method": "one-click"
  }
}

Retry schedule

If your endpoint returns a non-2xx response or times out (15-second limit), Taifa Mail retries with backoff:

AttemptDelay after failure
11 minute
25 minutes
330 minutes
42 hours
58 hours

After five failed attempts, Taifa Mail stops retrying that delivery and notifies you in-app. Inspect attempts from the dashboard under Developer → Webhooks → select webhook → Deliveries.


Signature verification

Every webhook request includes an X-TaifaMail-Signature header (sha256= + HMAC-SHA256 hex) over the canonical string built from X-TaifaMail-Timestamp, X-TaifaMail-Delivery-ID, X-TaifaMail-Event, and the raw JSON body, signed with your webhook secret. Reject timestamps older than five minutes to block replays.

Always verify this signature before processing the payload.

Python

import hmac
import hashlib
import time
 
def verify_webhook(
    body: bytes,
    signature: str,
    secret: str,
    *,
    timestamp: str,
    delivery_id: str,
    event_type: str,
    max_age_seconds: int = 300,
) -> bool:
    if abs(time.time() - int(timestamp)) > max_age_seconds:
        return False
    content = f"{timestamp}.{delivery_id}.{event_type}.{body.decode()}"
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"),
        content.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
 
# In your endpoint handler:
body = request.body()  # raw bytes
if not verify_webhook(
    body,
    request.headers["X-TaifaMail-Signature"],
    "whsec_your_signing_secret",
    timestamp=request.headers["X-TaifaMail-Timestamp"],
    delivery_id=request.headers["X-TaifaMail-Delivery-ID"],
    event_type=request.headers["X-TaifaMail-Event"],
):
    return Response(status_code=401)

Node.js

import crypto from "crypto";
 
function verifyWebhook(body, signature, secret, { timestamp, deliveryId, eventType }) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > 300) return false;
  const content = `${timestamp}.${deliveryId}.${eventType}.${body}`;
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(content).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
 
// In your Express handler:
app.post("/webhooks/taifamail", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyWebhook(req.body, req.headers["x-taifamail-signature"], "whsec_your_signing_secret", {
    timestamp: req.headers["x-taifamail-timestamp"],
    deliveryId: req.headers["x-taifamail-delivery-id"],
    eventType: req.headers["x-taifamail-event"],
  });
  if (!ok) return res.status(401).send("Invalid signature");
  res.status(200).send("OK");
});

Go

func verifyWebhook(body []byte, signature, secret, timestamp, deliveryID, eventType string) bool {
    content := fmt.Sprintf("%s.%s.%s.%s", timestamp, deliveryID, eventType, string(body))
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(content))
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

Always use constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js, hmac.Equal in Go) to prevent timing attacks.

Your webhook signing secret is shown when you create the webhook. You can also find it under Developer → Webhooks → click the webhook → Signing secret.