Receive real-time HTTP notifications when email events occur — deliveries, bounces, opens, clicks, and more. Multi-language handler examples for Python, Node.js, Ruby, and Go.
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.
RFC 3463 status code from the server (e.g., 5.1.1, 4.2.2)
remote_host
Hostname of the SMTP server that returned the error
Classification Categories
Category
Code
Description
invalid_recipient
10
Mailbox doesn’t exist
inactive_mailbox
11
Account disabled/suspended
mailbox_full
12
Over quota
invalid_domain
20
Domain doesn’t exist
dns_failure
21
Temporary DNS failure
routing_error
22
Relay denied
spam_block
30
IP/domain blocklisted
spam_content
31
Content flagged as spam
policy_rejection
32
Policy rejection (, etc.)
connection_error
40
Connection timeout/refused
protocol_error
41
SMTP protocol issues
transient_failure
42
General temporary failure
unclassified
99
Could not classify
Details Message Formats
The details field provides a human-readable summary. Format varies by status:
Status
Format
Example
Sent
Message 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 error
Connection error: Could not connect to any mail server for {domain}
Connection error: Could not connect to any mail server for example.com
Max attempts
Maximum 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.
from fastapi import FastAPI, Request, HTTPExceptionimport jsonimport timeapp = 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)
Copy
import express from 'express';const app = express();app.use(express.json());// In-memory store (use Redis/DB in production)const processedEvents = new Set<string>();app.post('/webhooks/ark', async (req, res) => { // 1. Verify signature (see Security page) verifySignature(req); const payload = req.body; // 2. Idempotency: prevent duplicate processing const token = payload.message?.token ?? ''; const status = payload.status ?? ''; const idempotencyKey = `${token}_${status}`; if (processedEvents.has(idempotencyKey)) { return res.status(200).send('OK'); } processedEvents.add(idempotencyKey); // 3. Process based on status (queue to background job in production) switch (payload.status) { case 'Sent': await handleSent(payload); break; case 'HardFail': await handleHardFail(payload); break; case 'SoftFail': // Ark retries automatically break; case 'Held': await handleHeld(payload); break; } // Handle bounces (different payload structure) if (payload.original_message) { await handleBounce(payload); } // Handle engagement events if (payload.ip_address) { if (payload.url) { await handleClick(payload); } else { await handleOpen(payload); } } // 4. Return 200 quickly res.status(200).send('OK');});async function handleHardFail(payload: any) { const { classification } = payload; const recipient = payload.message?.to; if (['invalid_recipient', 'inactive_mailbox', 'invalid_domain'].includes(classification)) { await updateContactStatus(recipient, 'invalid'); } else if (['spam_block', 'policy_rejection'].includes(classification)) { await alertDeliverabilityTeam(payload); }}
Copy
class ArkWebhooksController < ApplicationController skip_before_action :verify_authenticity_token def create # 1. Verify signature (see Security page) verify_signature! payload = JSON.parse(request.body.read) # 2. Idempotency: prevent duplicate processing idempotency_key = "#{payload.dig('message', 'token')}_#{payload['status']}" return head :ok if already_processed?(idempotency_key) mark_as_processed!(idempotency_key) # 3. Process based on status (queue to background job in production) process_payload(payload) # 4. Return 200 quickly head :ok rescue => e head :unauthorized end private def already_processed?(key) Rails.cache.exist?("ark_webhook:#{key}") end def mark_as_processed!(key) Rails.cache.write("ark_webhook:#{key}", true, expires_in: 24.hours) end def process_payload(payload) case payload['status'] when 'Sent' handle_sent(payload) when 'HardFail' handle_hard_fail(payload) when 'SoftFail' # Ark retries automatically Rails.logger.info "Soft fail: #{payload['classification']}" when 'Held' handle_held(payload) end handle_bounce(payload) if payload['original_message'] if payload['ip_address'] payload['url'] ? handle_click(payload) : handle_open(payload) end end def handle_hard_fail(payload) recipient = payload.dig('message', 'to') case payload['classification'] when 'invalid_recipient', 'inactive_mailbox', 'invalid_domain' update_contact_status(recipient, :invalid) when 'spam_block', 'policy_rejection' DeliverabilityAlert.notify(payload) end endend
Copy
package mainimport ( "encoding/json" "fmt" "net/http" "sync")type WebhookPayload struct { Message struct { Token string `json:"token"` To string `json:"to"` } `json:"message"` Status string `json:"status"` Classification string `json:"classification"` OriginalMessage *json.RawMessage `json:"original_message,omitempty"` IPAddress string `json:"ip_address,omitempty"` URL string `json:"url,omitempty"`}var ( processed = make(map[string]bool) mu sync.RWMutex)func webhookHandler(w http.ResponseWriter, r *http.Request) { // 1. Verify signature (see Security page) if err := verifySignature(r); err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } var payload WebhookPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } // 2. Idempotency: prevent duplicate processing key := fmt.Sprintf("%s_%s", payload.Message.Token, payload.Status) mu.RLock() if processed[key] { mu.RUnlock() w.WriteHeader(http.StatusOK) return } mu.RUnlock() mu.Lock() processed[key] = true mu.Unlock() // 3. Process based on status (queue to background job in production) switch payload.Status { case "Sent": handleSent(payload) case "HardFail": handleHardFail(payload) case "SoftFail": // Ark retries automatically case "Held": handleHeld(payload) } if payload.OriginalMessage != nil { handleBounce(payload) } if payload.IPAddress != "" { if payload.URL != "" { handleClick(payload) } else { handleOpen(payload) } } // 4. Return 200 quickly w.WriteHeader(http.StatusOK)}func handleHardFail(payload WebhookPayload) { switch payload.Classification { case "invalid_recipient", "inactive_mailbox", "invalid_domain": updateContactStatus(payload.Message.To, "invalid") case "spam_block", "policy_rejection": alertDeliverabilityTeam(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.
If your endpoint doesn’t return a 2xx status, Ark retries with exponential backoff:
Attempt
Delay
Cumulative Time
1
Immediate
0
2
5 seconds
5s
3
5 minutes
5m
4
30 minutes
35m
5
2 hours
2h 35m
6
5 hours
7h 35m
7
10 hours
17h 35m
8
24 hours
1d 17h
9
48 hours
3d 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.