Skip to content
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.

Signed mode requires a secret key that must stay on your server. Never put it in browser-side JavaScript, a mobile app bundle, or anywhere else a visitor could extract it — if the secret leaks, so does the ability to forge signed submissions. Use token mode for anything a browser submits directly.

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: no sha256= prefix, and not the t=…,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.

The signature covers the exact bytes of the request body. Sign the string you're about to send, not a re-serialized version of it — if your HTTP client re-encodes the body after you've computed the signature, verification will fail.

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
});

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. _hp is 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 _hp out 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 a Retry-After header 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