Skip to main content
Webhooks send HTTP POST requests to your server when email events occur. Instead of polling the API, get notified instantly when emails are delivered, bounced, opened, or clicked.

How It Works

  1. Register a webhook URL with Ark
  2. Subscribe to the events you care about
  3. Ark sends signed POST requests when events occur
  4. Your server verifies the signature, processes the event, and returns 200

Available Events

EventTriggerKey Fields
MessageSentEmail accepted by recipient’s serverstatus: "Sent", SMTP output
MessageDelayedTemporary delivery failure, will retrystatus: "SoftFail", classification
MessageDeliveryFailedPermanent delivery failure (hard bounce)status: "HardFail", classification, smtp_enhanced_code
MessageBouncedNDR received after initial deliveryoriginal_message, bounce
MessageHeldEmail held for reviewstatus: "Held"
MessageLoadedRecipient opened the emailip_address, user_agent
MessageLinkClickedRecipient clicked a linkip_address, user_agent, url
DomainDNSErrorDNS configuration issue detectedserver, domain, DNS status
SendLimitApproachingVolume at 90% of hourly limitvolume, limit
SendLimitExceededHourly send limit exceededvolume, limit
See the sidebar for detailed payload documentation for each event.

Creating a Webhook

from ark import Ark

client = Ark()

webhook = client.webhooks.create(
    name="Production Webhook",
    url="https://yourapp.com/webhooks/ark",
    events=["MessageSent", "MessageBounced", "MessageDeliveryFailed"]
)

print(f"Webhook created: {webhook.data.id}")

Per-Tenant Webhooks

For white-label platforms, create webhooks scoped to a specific tenant:
webhook = client.webhooks.create(
    name="Tenant A Webhook",
    url="https://yourapp.com/webhooks/ark",
    events=["MessageSent", "MessageBounced", "MessageDeliveryFailed"],
    tenant_id="tenant_abc123"
)

Request Headers

Each webhook request includes these headers:
HeaderDescriptionExample
Content-TypeAlways JSONapplication/json
X-Ark-SignatureeKnOE3Cwo84q3znFgh...
X-Ark-Signature-KIDKey ID for JWKS lookup2a9fb525bafa5a29...

Signature Verification

Full RSA-SHA256 verification implementation in Python, Node.js, Ruby, and Go

Payload Structure

Webhook payloads are sent directly without a wrapper. The structure varies by event type.

Delivery Events (MessageSent, MessageDelayed, MessageDeliveryFailed)

{
  "message": {
    "id": 12345,
    "token": "wgnsKieD5t8T0mc7",
    "direction": "outgoing",
    "message_id": "d00368d9-8ce9-40d2-96fc-0f0c10a43d00@rp.arkhq.io",
    "to": "user@example.com",
    "from": "hello@yourdomain.com",
    "subject": "Welcome to our service",
    "timestamp": 1704671990.308788,
    "spam_status": "NotChecked",
    "tag": "onboarding",
    "metadata": {
      "user_id": "usr_123456",
      "campaign_id": "camp_789"
    }
  },
  "status": "Sent",
  "details": "Message for user@example.com accepted by 1.2.3.4:25 (mx.example.com)",
  "output": "250 2.0.0 OK",
  "sent_with_ssl": true,
  "timestamp": 1704672000.123456,
  "time": 2.11
}

Bounce Classification

For MessageDelayed and MessageDeliveryFailed events, additional fields explain the failure:
{
  "message": { "..." : "..." },
  "status": "HardFail",
  "details": "550 invalid recipient: Delivery to user@example.com failed at mx.example.com",
  "output": "550 5.1.1 The email account that you tried to reach does not exist",
  "classification": "invalid_recipient",
  "classification_code": 10,
  "smtp_enhanced_code": "5.1.1",
  "remote_host": "mx.example.com"
}
FieldDescription
classificationCategory explaining the failure (e.g., invalid_recipient, mailbox_full, spam_block)
classification_codeNumeric code for grouping: 10-19 (recipient), 20-29 (domain), 30-39 (reputation), 40-49 (technical)
smtp_enhanced_codeRFC 3463 status code from the server (e.g., 5.1.1, 4.2.2)
remote_hostHostname of the SMTP server that returned the error
CategoryCodeDescription
invalid_recipient10Mailbox doesn’t exist
inactive_mailbox11Account disabled/suspended
mailbox_full12Over quota
invalid_domain20Domain doesn’t exist
dns_failure21Temporary DNS failure
routing_error22Relay denied
spam_block30IP/domain blocklisted
spam_content31Content flagged as spam
policy_rejection32Policy rejection (, etc.)
connection_error40Connection timeout/refused
protocol_error41SMTP protocol issues
transient_failure42General temporary failure
unclassified99Could not classify
The details field provides a human-readable summary. Format varies by status:
StatusFormatExample
SentMessage for {recipient} accepted by {ip}:{port} ({hostname})Message for user@example.com accepted by 1.2.3.4:25 (mx.example.com)
HardFail{code} {classification}: Delivery to {recipient} failed at {hostname}550 invalid recipient: Delivery to user@example.com failed at mx.example.com
SoftFail{code} {classification}: Delivery to {recipient} failed at {hostname}451 transient failure: Delivery to user@example.com failed at mx.example.com
Connection errorConnection error: Could not connect to any mail server for {domain}Connection error: Could not connect to any mail server for example.com
Max attemptsMaximum number of delivery attempts ({n}) has been reached...Maximum number of delivery attempts (18) has been reached...
Identifying event types: The event type is determined by which events you subscribe to. For delivery events, use the status field:
  • Sent → MessageSent
  • SoftFail → MessageDelayed
  • HardFail → MessageDeliveryFailed
  • Held → MessageHeld
Use metadata for correlation: The metadata object contains the custom key-value pairs you attached when sending the email. Use it to correlate webhook events with records in your database.

Handling Webhooks

from fastapi import FastAPI, Request, HTTPException
import json
import time

app = FastAPI()

# In-memory store (use Redis/DB in production)
processed_events = {}

@app.post("/webhooks/ark")
async def handle_webhook(request: Request):
    # 1. Verify signature (see Security page)
    verify_signature(request)

    payload = await request.json()

    # 2. Idempotency: prevent duplicate processing
    token = payload.get("message", {}).get("token", "")
    status = payload.get("status", "")
    idempotency_key = f"{token}_{status}"

    if idempotency_key in processed_events:
        return {"status": "ok"}
    processed_events[idempotency_key] = time.time()

    # 3. Process based on status (queue to background job in production)
    match payload.get("status"):
        case "Sent":
            await handle_sent(payload)
        case "HardFail":
            await handle_hard_fail(payload)
        case "SoftFail":
            pass  # Ark retries automatically
        case "Held":
            await handle_held(payload)

    # Handle bounces (different payload structure)
    if "original_message" in payload:
        await handle_bounce(payload)

    # Handle engagement events
    if "ip_address" in payload:
        if "url" in payload:
            await handle_click(payload)
        else:
            await handle_open(payload)

    # 4. Return 200 quickly
    return {"status": "ok"}

async def handle_hard_fail(payload):
    classification = payload.get("classification", "")
    recipient = payload.get("message", {}).get("to", "")

    if classification in ("invalid_recipient", "inactive_mailbox", "invalid_domain"):
        # Ark auto-suppresses, but update your own records
        await update_contact_status(recipient, "invalid")
    elif classification in ("spam_block", "policy_rejection"):
        await alert_deliverability_team(payload)
Return 200 immediately, then process asynchronously. Queue webhook payloads to a background job (Celery, Sidekiq, Bull, etc.) for processing. This prevents timeouts and ensures reliable delivery.

Retry Policy

If your endpoint doesn’t return a 2xx status, Ark retries with exponential backoff:
AttemptDelayCumulative Time
1Immediate0
25 seconds5s
35 minutes5m
430 minutes35m
52 hours2h 35m
65 hours7h 35m
710 hours17h 35m
824 hours1d 17h
948 hours3d 17h
After 9 failed attempts (~3 days), the delivery is marked as failed. Replay failed deliveries from the dashboard or via the API.
Failure notifications: If your webhook fails consistently for 24 hours with 10+ failures and no successes, organization admins receive an email notification. One notification per webhook per 24 hours.

Event Ordering

Events may arrive out of order. For example:
  • MessageBounced before MessageSent for the same email
  • MessageLoaded (open) before MessageSent
This happens due to network latency, retry timing, or parallel processing.
Design your handlers to be order-independent. Use the message token to correlate events and timestamp to determine actual sequence when needed.

Testing

Send a test event to verify your endpoint:
curl -X POST https://api.arkhq.io/v1/webhooks/{webhookId}/test \
  -H "Authorization: Bearer $ARK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event": "MessageSent"}'

Next Steps