Webhooks
Outbound webhooks let your app react to ampout state changes in real time — without polling. When a contact replies, an email bounces, a credential gets disabled, your endpoint fires.
For management endpoints (creating, listing, rotating secrets), see API → Webhooks. This page covers the receiving side: payload shape, signature verification, what each event means.
Event types
Section titled “Event types”The 7 events emitted in v1:
| Event | Fires when |
|---|---|
enrollment.sent | A SendEmailJob successfully delivered to SMTP. One per step fire (so a 3-step campaign emits 3 events per contact). |
enrollment.opened | Tracking pixel loaded (first open only — repeat opens don’t re-fire). |
enrollment.replied | ReplyDetectionJob matched an inbound IMAP message to this enrollment. Auto-replies are filtered out. |
enrollment.bounced | Synchronous SMTP error (Net::SMTPFatalError etc.) OR async bounce reported by SMTP2GO inbound webhook. |
enrollment.unsubscribed | One-click unsubscribe link clicked OR spam complaint via SMTP2GO inbound webhook. One per active enrollment for the contact. |
credential.disabled | CampaignHealthMonitorJob auto-disabled an SMTP credential because its bounce rate exceeded the campaign’s threshold. |
campaign.paused | CampaignHealthMonitorJob auto-paused a campaign because all its credentials are disabled. |
You subscribe to a subset (or all) via the webhook’s event_filters array. ["*"] is the wildcard (default). ["enrollment.replied"] is exact-match.
Payload shape
Section titled “Payload shape”Every event arrives as a JSON POST with this envelope:
{ "id": "<delivery_uuid>", "type": "enrollment.replied", "created_at": "2026-04-28T15:00:00Z", "account_id": "<your_account_uuid>", "data": { ... }}data varies by event type:
enrollment.sent / .opened / .replied / .bounced
Section titled “enrollment.sent / .opened / .replied / .bounced”{ "enrollment_id": "...", "contact_id": "...", "campaign_id": "...", "status": "sent", "sent_at": "...", "replied_at": null, "bounce_reason": null}status, sent_at, replied_at, bounce_reason reflect the post-event state.
enrollment.unsubscribed
Section titled “enrollment.unsubscribed”{ "enrollment_id": "...", "contact_id": "...", "campaign_id": "...", "unsubscribed_at": "..."}Fires once per active enrollment, so if a contact was in 3 campaigns, you get 3 events.
credential.disabled
Section titled “credential.disabled”{ "credential_id": "...", "campaign_id": "...", "bounce_rate": 8.42, "threshold": 8.0}campaign.paused
Section titled “campaign.paused”{ "campaign_id": "...", "name": "Q2 launch", "reason": "all_credentials_disabled"}Signature verification (Stripe-style)
Section titled “Signature verification (Stripe-style)”Every webhook POST includes:
X-Ampout-Signature: t=1714000000,v1=<hmac-sha256-hex>Content-Type: application/jsonUser-Agent: ampout-webhooks/1.0The signature is HMAC-SHA256 over "<timestamp>.<raw_body>" using your webhook’s secret as the key. Receivers should:
- Parse
t=...,v1=...from the header. - Reconstruct
signed_payload = f"{timestamp}.{raw_body}". - Compute
expected = HMAC-SHA256(secret, signed_payload). - Compare constant-time. And enforce a 5-minute timestamp tolerance to prevent replay.
Node.js verifier
Section titled “Node.js verifier”const crypto = require('node:crypto');
function verifyAmpoutSignature(rawBody, header, secret) { const [tPart, v1Part] = header.split(','); const timestamp = parseInt(tPart.split('=')[1], 10); const signature = v1Part.split('=')[1];
// Replay protection: 5-minute tolerance. const ageSeconds = Math.floor(Date.now() / 1000) - timestamp; if (ageSeconds > 300 || ageSeconds < -60) return false;
const signedPayload = `${timestamp}.${rawBody}`; const expected = crypto .createHmac('sha256', secret) .update(signedPayload) .digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));}
// Express example:app.post('/webhooks/ampout', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyAmpoutSignature( req.body.toString('utf8'), req.headers['x-ampout-signature'], process.env.AMPOUT_WEBHOOK_SECRET ); if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(req.body.toString('utf8')); console.log(event.type, event.data); res.sendStatus(200);});Ruby verifier
Section titled “Ruby verifier”def verify_ampout_signature(raw_body, header, secret) parts = Hash[header.split(',').map { |p| p.split('=', 2) }] timestamp = parts['t'].to_i signature = parts['v1']
return false if (Time.now.to_i - timestamp).abs > 300
signed_payload = "#{timestamp}.#{raw_body}" expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
ActiveSupport::SecurityUtils.secure_compare(expected, signature)endPython verifier
Section titled “Python verifier”import hmac, hashlib, time
def verify_ampout_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split('=', 1) for p in header.split(',')) timestamp = int(parts['t']) signature = parts['v1']
if abs(time.time() - timestamp) > 300: return False
signed_payload = f"{timestamp}.{raw_body.decode()}".encode() expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)Retry & auto-disable
Section titled “Retry & auto-disable”Failed deliveries (5xx, 408, 429, network errors) retry with polynomial backoff up to 5 attempts. 4xx responses (other than 408/429) do not retry — they’re treated as bad config (your endpoint actively rejected the payload).
After 5 consecutive delivery failures (one delivery = up to 5 attempts each), the webhook is auto-disabled. The disabled_at timestamp is set; future events skip the webhook entirely.
To re-enable after fixing your receiver:
curl -X PATCH "https://ampout.fly.dev/webhooks/$WEBHOOK_ID" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"webhook": {"disabled_at": null}}'consecutive_failures resets to 0 on the next successful delivery (2xx).
Idempotency
Section titled “Idempotency”Each event has a unique id (the webhook delivery UUID). If your receiver fires a side effect, dedupe by event id — Stripe-style. Ampout itself sends each event at-least-once under the retry policy above.
Stripe inbound
Section titled “Stripe inbound”POST /webhooks/stripe is inbound (Stripe → ampout) and not the same thing as your outbound webhooks. It handles customer.subscription.created/updated/deleted, invoice.paid, and invoice.payment_failed to keep account.plan in sync with the Stripe subscription state. You don’t subscribe to it — Stripe does.