Docs Signed mode (HMAC)
Docs
Signed mode (HMAC)
Token mode is enough for any HTML form — the URL itself can't be guessed or brute-forced, and that's a reasonable bar for a public web form. Signed mode adds a second layer for server-to-server integrations: proof the request really came from your server, and protection against someone capturing and replaying an old request.
Turning it on
Switch an endpoint to signed mode from its Security settings in the dashboard. PostTo generates a secret key, shown once — store it in your server's environment/secrets, not your codebase.
You need both the token and the secret
Signed mode is added on top of token mode, not a replacement for it. Requests still go to your endpoint URL, which contains its token
(/api/v1/send/YOUR_TOKEN), and the secret key is used only to compute the signature. Keep both values in your server's
environment, for example POSTTO_URL and POSTTO_SECRET. Having the secret without the token is not enough.
How the signature is built
Two headers travel with every signed request:
X-PostTo-Timestamp— the current Unix timestamp in seconds, as a string of digits.X-PostTo-Signature—HMAC-SHA256(secret, "<timestamp>.<raw request body>")as lowercase hex, with no prefix. Send the bare digest: nosha256=prefix, and not thet=…,v1=…format, which is used only on outgoing webhooks.
PostTo recomputes the same HMAC on its end and rejects the request (invalid_signature, 401) if it doesn't match, or if the
timestamp is too far from the current time — which is what stops a captured request from being replayed later. The default tolerance is 300
seconds (5 minutes); you can tighten it to 60 seconds or relax it to 900 seconds per endpoint in Security settings.
Each signature is accepted only once. If you retry a request, sign it again with a new timestamp. Resending the exact same signed request
returns invalid_signature.
Examples
const crypto = require('crypto');
const ts = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify({
subject: 'Contact form',
message: 'Hello, I\'d like to...',
email: 'sender@example.com',
name: 'Jane Smith',
});
const sig = crypto.createHmac('sha256', SECRET_KEY)
.update(`${ts}.${body}`).digest('hex');
await fetch('https://postto.dev/api/v1/send/YOUR_TOKEN', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-PostTo-Timestamp': ts,
'X-PostTo-Signature': sig,
},
body, // must be the exact bytes that were signed
});
$url = 'https://postto.dev/api/v1/send/YOUR_TOKEN';
$body = json_encode([
'subject' => 'Contact form',
'message' => "Hello, I'd like to...",
'email' => 'sender@example.com',
'name' => 'Jane Smith',
]);
$ts = time();
$sig = hash_hmac('sha256', "{$ts}.{$body}", SECRET_KEY);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
"X-PostTo-Timestamp: {$ts}",
"X-PostTo-Signature: {$sig}",
],
]);
curl_exec($ch);
curl_close($ch);
$body = json_encode([
'subject' => 'Contact form',
'message' => "Hello, I'd like to...",
'email' => 'sender@example.com',
'name' => 'Jane Smith',
]);
$ts = now()->unix();
$sig = hash_hmac('sha256', "{$ts}.{$body}", config('services.postto.secret'));
Http::withHeaders([
'Accept' => 'application/json',
'X-PostTo-Timestamp' => $ts,
'X-PostTo-Signature' => $sig,
])->withBody($body, 'application/json')
->post('https://postto.dev/api/v1/send/YOUR_TOKEN');
import hmac, hashlib, json, time, requests
body = json.dumps({
'subject': 'Contact form',
'message': "Hello, I'd like to...",
'email': 'sender@example.com',
'name': 'Jane Smith',
})
ts = str(int(time.time()))
sig = hmac.new(SECRET_KEY.encode(), f'{ts}.{body}'.encode(), hashlib.sha256).hexdigest()
requests.post(
'https://postto.dev/api/v1/send/YOUR_TOKEN',
data=body,
headers={
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-PostTo-Timestamp': ts,
'X-PostTo-Signature': sig,
},
)
Signed requests always receive a JSON response, regardless of any Accept header — see response format.
What still applies to signed requests
A valid signature proves who sent the request. It does not bypass the other checks:
- Rate limits. The per-endpoint rate limit applies. The per-IP limit does not: every request comes from your own server, so a per-visitor limit can't work here. Set the endpoint's rate limit (Settings, up to 10,000/hour) to fit your real volume, and rate-limit individual visitors in your own app.
- Monthly quota. Signed submissions count against your plan the same way as any other submission.
- Honeypot.
_hpis honoured. If the body contains a non-empty_hp, PostTo returns{"id": null, "status": "ok"}and discards the submission. If you forward a browser form's fields as they are, its honeypot keeps working. If you filter spam in your own app, leave_hpout of the body. - Spam filtering (heuristics, and AI if enabled) runs on every submission, signed or not.
- Turnstile. If Turnstile is enabled on the endpoint, the body must include a valid
cf-turnstile-response. Server-to-server integrations usually leave Turnstile off.
Handling errors and retries
Use the error code to decide what to do:
rate_limited(429): the response has aRetry-Afterheader with the number of seconds until the limit resets. Wait that long, then re-sign the request and send it again.quota_exceeded(402): the monthly quota is used up. Retrying will not help until the next month or a plan change.invalid_signature(401): check the secret, the clock on your server, and that you send exactly the bytes you signed. Do not retry with the same signature.
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