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.
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:
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;
}
}
function isValid(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
[$tsPart, $sigPart] = explode(',', $header, 2);
$ts = substr($tsPart, 2);
$sig = substr($sigPart, 3);
if (abs(time() - (int) $ts) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', "{$ts}.{$rawBody}", $secret);
return hash_equals($expected, $sig);
}
use Illuminate\Http\Request;
public function handle(Request $request): \Illuminate\Http\Response
{
$rawBody = $request->getContent();
$header = $request->header('X-PostTo-Signature', '');
$secret = config('services.postto.webhook_secret');
if (! $this->isValidSignature($rawBody, $header, $secret)) {
abort(403, 'Invalid signature');
}
$payload = $request->json()->all();
if ($payload['event'] === 'submission.received') {
// Handle the submission...
}
return response()->noContent();
}
private function isValidSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = explode('=', $part, 2);
$parts[$key] = $value;
}
if (! isset($parts['t'], $parts['v1'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', "{$parts['t']}.{$rawBody}", $secret);
return hash_equals($expected, $parts['v1']);
}
import hmac, hashlib, time
def is_valid(raw_body: str, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in header.split(','))
ts, sig = parts['t'], parts['v1']
if abs(time.time() - int(ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), f'{ts}.{raw_body}'.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
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