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": "sender@example.com",
    "fields": {
      "subject": "Contact form",
      "message": "Hello, I'd like to...",
      "email": "sender@example.com",
      "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 HMAC is computed the same way as for signed submissions, but the header format is different (signed submissions use two separate headers) and the key is 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) is retried up to 4 times (5 attempts in total) with backoff: 30 seconds, 1 minute, 2 minutes, then 5 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