Skip to content
Docs Webhooks

Docs

Webhooks

A webhook forwards every submission to your own server as an HTTP POST — in addition to the email delivery, not instead of it. Useful for piping submissions into a CRM, your own database, or a background job — anywhere that can receive and verify a signed JSON payload. Available on Pro and Scale plans; see plans.

The payload is PostTo's own JSON shape (below), not a pre-formatted message for a specific chat app. To post into Slack or Discord, send this webhook to a small relay (a serverless function, or a tool like Zapier/Pipedream/n8n) that reformats it into what that app's incoming webhook expects — pointing this URL directly at a Slack/Discord webhook URL won't produce a readable message.

Setting one up

Add a webhook URL under an endpoint's Webhooks settings. PostTo generates a secret for it, shown once — you'll use it to verify incoming requests really came from PostTo. Each configured webhook receives its own delivery, queued independently of the email send, so a slow or failing webhook never delays the email.

The payload

Currently the only event is submission.received, fired once per submission that isn't silently discarded by the honeypot:

Webhook request
POST <your webhook URL>
Content-Type: application/json
X-PostTo-Signature: t=1718901234,v1=5f8c1e2a...

{
  "event": "submission.received",
  "endpoint_id": "ep_01JXF...",
  "submission": {
    "id": "sub_01JXF...",
    "status": "pending",
    "subject": "Contact form",
    "message": "Hello, I'd like to...",
    "sender_name": "Jane Smith",
    "sender_email": "[email protected]",
    "fields": {
      "subject": "Contact form",
      "message": "Hello, I'd like to...",
      "email": "[email protected]",
      "name": "Jane Smith"
    },
    "signature_verified": null,
    "spam_score": 0.12,
    "created_at": "2026-06-20T14:32:00+00:00"
  }
}

fields contains every field the submission included, unmapped and under its original names. subject, message, sender_name, and sender_email are the resolved canonical roles (after field mapping) and may be null if nothing filled that role. spam_score is null whenever AI scoring is off, dormant, or the check failed open — never treat a missing score as "this is spam" or "this is safe."

Verifying the signature

Every delivery carries an X-PostTo-Signature header in the form t=<timestamp>,v1=<hmac> — the same scheme used for signed submissions, but keyed with this webhook's own secret, not your endpoint's. Split on the comma, recompute HMAC-SHA256("<t>.<raw body>", secret), and compare with a constant-time check. Reject anything with a timestamp too far in the past.

const crypto = require('crypto');

function isValid(rawBody, header, secret, toleranceSeconds = 300) {
  const [tsPart, sigPart] = header.split(',');
  const ts = tsPart.split('=')[1];
  const sig = sigPart.split('=')[1];

  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSeconds) {
    return false;
  }

  const expected = crypto.createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`).digest('hex');

  try {
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  } catch {
    return false;
  }
}

Retries & auto-disable

A failed delivery (non-2xx response, or a connection error) retries up to 5 times with backoff: 30 seconds, 1 minute, 2 minutes, 5 minutes, 10 minutes. After 10 consecutive failures, the webhook is automatically disabled and you're notified by email — fix the issue and re-enable it from the Webhooks tab.

Try it against your own form

Create an endpoint and get a working URL in under a minute — free plan, no credit card.

Start for free