Skip to content

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.

The 7 events emitted in v1:

EventFires when
enrollment.sentA SendEmailJob successfully delivered to SMTP. One per step fire (so a 3-step campaign emits 3 events per contact).
enrollment.openedTracking pixel loaded (first open only — repeat opens don’t re-fire).
enrollment.repliedReplyDetectionJob matched an inbound IMAP message to this enrollment. Auto-replies are filtered out.
enrollment.bouncedSynchronous SMTP error (Net::SMTPFatalError etc.) OR async bounce reported by SMTP2GO inbound webhook.
enrollment.unsubscribedOne-click unsubscribe link clicked OR spam complaint via SMTP2GO inbound webhook. One per active enrollment for the contact.
credential.disabledCampaignHealthMonitorJob auto-disabled an SMTP credential because its bounce rate exceeded the campaign’s threshold.
campaign.pausedCampaignHealthMonitorJob 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.

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": "...",
"contact_email": "lead@acme.com",
"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_id": "...",
"contact_id": "...",
"contact_email": "lead@acme.com",
"campaign_id": "...",
"unsubscribed_at": "..."
}

Fires once per active enrollment, so if a contact was in 3 campaigns, you get 3 events.

{
"credential_id": "...",
"from_email": "ada@yourdomain.com",
"campaign_id": "...",
"bounce_rate": 8.42,
"threshold": 8.0
}
{
"campaign_id": "...",
"name": "Q2 launch",
"reason": "all_credentials_disabled"
}

Every webhook POST includes:

X-Ampout-Signature: t=1714000000,v1=<hmac-sha256-hex>
Content-Type: application/json
User-Agent: ampout-webhooks/1.0

The signature is HMAC-SHA256 over "<timestamp>.<raw_body>" using your webhook’s secret as the key. Receivers should:

  1. Parse t=...,v1=... from the header.
  2. Reconstruct signed_payload = f"{timestamp}.{raw_body}".
  3. Compute expected = HMAC-SHA256(secret, signed_payload).
  4. Compare constant-time. And enforce a 5-minute timestamp tolerance to prevent replay.
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);
});
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)
end
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)

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:

Terminal window
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).

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.

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.