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": "[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;
}
}
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) 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