# Verify form webhook signatures

> Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.

Published 2026-10-25 · https://ikiform.com/guides/webhook-signature-verification

Recompute the signature yourself and compare it with the one in the request. Ikiform follows Standard Webhooks: the signature is a base64 HMAC-SHA256 of `webhook-id`, `webhook-timestamp` and the raw body joined by dots, keyed with the base64-decoded part of your `whsec_` secret, and sent as `v1,<signature>`. Accept the request only if one `v1` signature matches in a constant-time compare and the timestamp is within 5 minutes.

A webhook endpoint is a public URL that accepts POST requests from anyone. Without a signature check, anybody who learns the URL can send fake submissions into your CRM, your database or your team's chat. The check is a few lines of code, and getting those lines exactly right is what this guide is about.

## How signing works

Ikiform and your server share one secret, shown once when you add the endpoint. Before each delivery attempt, Ikiform hashes the request with that secret and puts the result in a header. Your server repeats the calculation on what it received. Only someone with the secret can produce a matching value, and any change to the body, id or timestamp breaks it.

Diagram: How Ikiform signs a webhook delivery and how your server checks it

1. Ikiform serializes the event as JSON.
2. It joins the delivery id, the attempt's Unix timestamp and the body with dots.
3. It computes an HMAC-SHA256 of that string with each active secret of the endpoint.
4. It sends each result, base64-encoded and prefixed with v1, in the webhook-signature header.
5. Your handler reads the raw body and the three headers before parsing anything.
6. It computes the same HMAC with its copy of the secret.
7. It compares in constant time against every v1 signature and checks the timestamp is within 5 minutes.
8. On a match it answers 2xx and handles the event. Otherwise it answers 400 and does nothing.

Nothing secret travels with the request. The signature proves the sender knew the secret, and that the bytes weren't changed on the way.

## The scheme, exactly

Ikiform uses [Standard Webhooks](https://www.standardwebhooks.com), an open spec several webhook providers share. Every delivery carries three headers:

| Header | Value | Notes |
| --- | --- | --- |
| `webhook-id` | `dlv_...` | The delivery id. The same on every retry, so it doubles as an idempotency key |
| `webhook-timestamp` | Unix seconds, such as `1792300000` | When this attempt was signed. New on every retry |
| `webhook-signature` | `v1,` plus base64 | One or more space-separated values. Two while a secret is being rolled |

`What is signed`:

```text
webhook-id:        dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body:          {"id":"dlv_01k7...","type":"endpoint.test",...}

signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key            = base64-decode(secret without "whsec_")
signature      = base64(HMAC-SHA256(key, signed content))
header         = "v1," + signature
```

To verify a delivery, in this order:

1. Read the raw body as bytes or an exact string. Don't parse it yet.
2. Read the three headers. If any is missing, reject.
3. Check the timestamp is an integer within 5 minutes of your clock, in either direction. If not, reject.
4. Strip `whsec_` from your secret and base64-decode the rest. Those 32 bytes are the HMAC key, not the string itself.
5. Compute HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}` and base64-encode it with the standard alphabet and padding.
6. Split `webhook-signature` on spaces. For each entry, split on the first comma; skip anything whose version isn't `v1`.
7. Compare each `v1` value with yours in constant time. If any matches, the delivery is genuine.
8. Only now parse the JSON and act on it.

## A test vector

Before you deploy a handler, prove it computes the right value. These inputs are a real `endpoint.test` delivery body signed with a made-up secret. Your code must produce exactly the signature in the last row. The language guides each include a unit test that uses them.

| Input | Value |
| --- | --- |
| Secret | `whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=` |
| `webhook-id` | `dlv_01k7v2d4n8r2p6s0w3y5b7c9e1` |
| `webhook-timestamp` | `1792300000` |
| Raw body | The exact bytes in the script below, with no spaces or newline |
| Expected `webhook-signature` | `v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=` |

You can check it without writing code. This runs in a terminal on macOS or Linux and prints the base64 part of the signature:

`Terminal`:

```bash
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
BODY='{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'

KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
  | base64
```

Two more cases are worth a test each, because they're the two most common real failures:

| Change | Signature | Should your handler accept it? |
| --- | --- | --- |
| The same body pretty-printed with two-space indents | `v1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=` | No. Same JSON, different bytes |
| `webhook-timestamp` one second later | `v1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=` | No. The timestamp is signed |
| Signed with a second secret during a roll | `v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=` | Yes, if the header also has a value your secret matches |

During a secret roll the header carries both values. Your handler, holding either secret, must accept this:

`Two signatures`:

```http
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
```

> The timestamp in the vector is fixed, so a real verifier rejects it as too old. Tests must pass a fixed current time, which the language guides do. The [signature verifier](https://ikiform.com/docs/webhooks.md#verifier) on the webhooks page checks any secret, headers and body in the browser.

## Ways to verify

| Approach | Good at | Trade-offs |
| --- | --- | --- |
| `verifyWebhook` from `@ikiform/sdk/webhooks` | One call that checks everything and returns a typed event. Runs on Node, Workers, Deno and Bun | JavaScript and TypeScript only |
| A Standard Webhooks library | Maintained code for Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# and Elixir | One more dependency. Check how each wants headers passed; some are case-sensitive |
| Your own HMAC code | No dependency, about 20 lines, and you can test every branch | Easy to get subtly wrong: string comparison, the wrong key bytes or a parsed body |
| A secret path or query token instead | Simple | Leaks through logs, proxies and error trackers, proves nothing about the body and can't expire. Not a substitute |

Ikiform signs every delivery, and the signature is the supported way to authenticate one. Its docs don't publish a fixed list of sending IP addresses, so an IP allowlist isn't a check you can rely on.

## Verify in your framework

For JavaScript stacks, the SDK does the work. What changes between frameworks is where the route lives and how to get the body before anything parses it. Pick yours:

**Next.js**

An App Router route handler. `request.text()` is the raw body; calling `request.json()` first would lose it. The [own server guide](https://ikiform.com/blog/form-submissions-to-your-own-server.md) adds dedupe and `after()`.

`app/api/webhooks/ikiform/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    console.info(event.type, request.headers.get("webhook-id"));
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}
```

**Astro**

An endpoint with a `POST` export. It needs on-demand rendering, so keep `prerender = false` and an adapter. `getSecret` from `astro:env/server` reads `IKIFORM_WEBHOOK_SECRET` at runtime and only on the server; `import.meta.env` would bake it into the build.

`src/pages/api/webhooks/ikiform.ts`:

```ts
import type { APIRoute } from "astro";
import { getSecret } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: getSecret("IKIFORM_WEBHOOK_SECRET") ?? "",
    });
    console.info(event.type, event.id);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
};
```

**TanStack Start**

A server route: the same file-based route, with a `server.handlers.POST` function that receives the standard `Request`.

`src/routes/api/webhooks/ikiform.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const Route = createFileRoute("/api/webhooks/ikiform")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const payload = await request.text();
        try {
          const event = await verifyWebhook({
            payload,
            headers: request.headers,
            secret: process.env.IKIFORM_WEBHOOK_SECRET!,
          });
          console.info(event.type, event.id);
          return new Response(null, { status: 204 });
        } catch (error) {
          if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
          throw error;
        }
      },
    },
  },
});
```

**React Router**

A resource route: a module with an `action` and no default export, registered in `routes.ts`. It answers Ikiform directly instead of rendering a page.

`app/routes.ts`:

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("webhooks/ikiform", "routes/webhooks.ikiform.ts"),
] satisfies RouteConfig;
```

`app/routes/webhooks.ikiform.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/webhooks.ikiform";

export async function action({ request }: Route.ActionArgs) {
  if (request.method !== "POST") return new Response(null, { status: 405 });
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    console.info(event.type, event.id);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}
```

**SvelteKit**

A `+server.ts` endpoint. SvelteKit's built-in CSRF check only blocks cross-site form content types, and Ikiform sends `application/json`, so no config change is needed. Declare `IKIFORM_WEBHOOK_SECRET` in `src/env.ts` with `defineEnvVars` so `$app/env/private` can read it.

`src/routes/webhooks/ikiform/+server.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { IKIFORM_WEBHOOK_SECRET } from "$app/env/private";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request }) => {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({ payload, headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
    console.info(event.type, event.id);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
};
```

**Nuxt**

A Nitro server route. `readRawBody` returns the body as a string before anything parses it. Set `NUXT_IKIFORM_WEBHOOK_SECRET` and declare `ikiformWebhookSecret` under `runtimeConfig` in `nuxt.config.ts`.

`server/api/webhooks/ikiform.post.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export default defineEventHandler(async (event) => {
  const payload = (await readRawBody(event, "utf8")) ?? "";
  const { ikiformWebhookSecret } = useRuntimeConfig(event);
  try {
    const delivery = await verifyWebhook({
      payload,
      headers: getRequestHeaders(event),
      secret: ikiformWebhookSecret,
    });
    console.info(delivery.type, delivery.id);
    setResponseStatus(event, 204);
    return null;
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      throw createError({ status: 400, message: error.reason });
    }
    throw error;
  }
});
```

Every handler answers `204` on success and `400` with the failure reason otherwise. Do slow work after answering; Ikiform waits 15 seconds before it counts an attempt as failed.

## Why the raw body matters

The signature covers bytes, not meaning. `{"a":1}` and `{ "a": 1 }` are the same JSON and different bytes, so a body that has been parsed and serialized again almost never matches. The same goes for anything that touches the body on the way in: middleware that parses JSON, a proxy that re-encodes, or a framework that hands you an object instead of a string.

Read the body once, as bytes or a string, verify it, then parse that same string. Each language guide shows where the raw body lives in its frameworks, and the [own server guide](https://ikiform.com/blog/form-submissions-to-your-own-server.md) has a table for the common Node and Python ones.

## Compare in constant time

A normal string comparison stops at the first different character. In theory, an attacker who can time thousands of requests learns how many leading characters of a guess are right and builds a valid signature one character at a time. A constant-time compare takes the same time however much matches. Every language has one:

| Language | Constant-time compare |
| --- | --- |
| Node.js | `crypto.timingSafeEqual(a, b)`, which throws if lengths differ, so check length first |
| Python | `hmac.compare_digest(a, b)` |
| Go | `hmac.Equal(a, b)` |
| PHP | `hash_equals($known, $user)` |
| Ruby | `OpenSSL.secure_compare(a, b)`, or `ActiveSupport::SecurityUtils.secure_compare` in Rails |

## Timestamps and replays

A valid request captured once stays valid forever unless something expires it. The signed timestamp is that something: reject any delivery more than 5 minutes from your clock, in either direction. Because Ikiform signs each attempt again with a fresh timestamp, a retry three days later still passes; only a copied old request fails.

Within those 5 minutes, a replay is still possible, and so is a genuine duplicate: Ikiform retries when it doesn't get a `2xx` in time, even if your handler finished. Store `webhook-id` with a unique constraint and skip ids you've already handled. That closes both.

> Keep the server clock synced. A clock that drifts by more than 5 minutes rejects every delivery with `timestamp_too_old` or `timestamp_too_new`. Managed platforms usually keep clocks synced; on your own VM, run NTP.

## Rolling the secret

Roll the secret when someone who knew it leaves, when it may have been logged or committed, or on a schedule. **Roll signing secret** on the endpoint page creates a new one, and for 24 hours every delivery is signed with both:

Diagram: What webhook-signature contains before, during and after a secret roll

1. Before the roll, each delivery carries one signature, made with the old secret.
2. You choose Roll signing secret and copy the new secret.
3. For the next 24 hours, each delivery carries two signatures, so a server holding either secret accepts it. Deploy the new secret in this window.
4. After 24 hours, deliveries carry only the new signature, and a server still holding the old secret rejects them.

Your handler needs no special code for this, as long as it checks every v1 entry instead of only the first.

If the old secret is still deployed after 24 hours, every delivery fails. Ikiform retries each one 8 times over about three days, so once you deploy the right secret, use **Retry** in the delivery log for anything that already gave up.

## After the check passes

A valid signature proves the request came from Ikiform and wasn't changed. It doesn't prove the content is safe or new. Three habits finish the job:

- **Skip repeats.** Store `webhook-id`, or the payload's `id`, which is the same value, with a unique constraint. A retry of a delivery you already handled becomes a no-op.
- **Answer first, work later.** Return `2xx` as soon as the event is stored or queued. Ikiform counts anything slower than 15 seconds as a failure and retries, which brings back the duplicate problem.
- **Treat fields as untrusted.** `data.fields` holds what a visitor typed into a public form. Escape it before rendering it in HTML, a chat message or an email, and never pass it to a shell or a query unparameterized.

### Testing before you go live

Ikiform only delivers to public `https:` URLs on port 443, so `localhost` can't receive a webhook. Run a tunnel such as `cloudflared tunnel --url http://localhost:3000` and add the URL it prints as a second endpoint while you work. **Send test** on the endpoint page sends an `endpoint.test` event once, with no retries, signed exactly like a real delivery. Delete the test endpoint when you're done; it has its own secret, so nothing else changes.

If a delivery fails and you can't see why, copy its headers and body from your logs into the [signature verifier](https://ikiform.com/docs/webhooks.md#verifier). It runs the same check in the browser and tells you which step failed.

## Five ways verification goes wrong

| Mistake | Symptom | Fix |
| --- | --- | --- |
| Using the whole `whsec_...` string as the key | `no_matching_signature` on every delivery | Strip `whsec_`, then base64-decode. The key is 32 bytes |
| Verifying a parsed body | `no_matching_signature`, sometimes only for some payloads | Verify the raw body; parse after |
| Checking only the first signature | Failures that start right after a roll | Loop over every space-separated `v1` entry |
| Hex instead of base64 | `no_matching_signature` | Standard Webhooks uses standard base64 with padding, not hex or base64url |
| Looking up headers by exact case | `missing_headers` behind some proxies | Header names are case-insensitive. Ikiform sends them lowercase; look them up case-insensitively |

There are no one-click integrations. Every tool is reached through a signed [webhook](https://ikiform.com/docs/webhooks.md), directly or through an automation tool such as [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). A workspace can have 3 endpoints, each with its own secret. The full payload, retry schedule and delivery log are in [Webhooks](https://ikiform.com/docs/webhooks.md).

## Pick your situation

Each language page has idiomatic handlers, a library option and a unit test that runs the test vector from this page.

- [Node.js](https://ikiform.com/guides/webhook-signature-verification/nodejs.md): Verify Ikiform webhook signatures in Node.js: raw bodies in Express and Fastify, the SDK, the standardwebhooks package and a tested node:crypto version.
- [Python](https://ikiform.com/guides/webhook-signature-verification/python.md): Verify Ikiform webhook signatures in Flask, FastAPI and Django, with the standardwebhooks package or the standard library, plus a pytest test.
- [Go net/http](https://ikiform.com/guides/webhook-signature-verification/go.md): Verify Ikiform webhook signatures in Go with net/http: read the body once, check it with crypto/hmac, test it, or use the Standard Webhooks library.
- [PHP](https://ikiform.com/guides/webhook-signature-verification/php.md): Verify Ikiform webhook signatures in plain PHP with hash_hmac and hash_equals, and in Laravel with the Standard Webhooks library and a CSRF exception.
- [Ruby](https://ikiform.com/guides/webhook-signature-verification/ruby.md): Verify Ikiform webhook signatures in Sinatra with OpenSSL and a Minitest test, and in Rails with the standardwebhooks gem and request.raw_post.

## Check it works

Do this on a staging endpoint first, then again on production. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from `localhost`. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See [pricing](https://ikiform.com/pricing).

1. Run the test vector through your code: it should produce the expected signature, accept the two-signature header and reject the pretty-printed body.
2. In the dashboard, open **Webhooks**, add your endpoint's public `https:` URL and copy the `whsec_` secret into your server's environment.
3. Choose **Send test** on the endpoint page. The delivery log shows your `204` and the event reaches your logs.
4. Change one character of the secret on your server and send another test. The log shows `400` with `no_matching_signature`.
5. Submit the form for real and check the `submission.created` delivery succeeds.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| `400 no_matching_signature` | Wrong secret, a parsed body, or a key that wasn't base64-decoded. Run the test vector through the same code path to see which. |
| `400 timestamp_too_old` or `timestamp_too_new` | The server clock is off, or you're replaying a saved request by hand. Sync the clock, and use **Retry** in the delivery log, which signs again. |
| `400 missing_headers` | A proxy or framework dropped the `webhook-*` headers, or the request didn't come from Ikiform. Log every header name your handler sees. |
| Works for test events, fails for real submissions | Real payloads contain non-ASCII characters and longer text. Your code decodes or re-encodes the body before verifying. Verify the exact bytes. |
| Every delivery fails right after rolling the secret | The handler only checks the first signature, or the 24 hours passed with the old secret deployed. Check every `v1` entry and deploy the new secret. |
| The delivery log shows a timeout, but your handler ran | Ikiform waits 15 seconds. Answer first and do slow work afterwards, and skip repeated `webhook-id` values. |

## Questions

### How do I verify a webhook signature?

Recompute the HMAC-SHA256 of the webhook id, timestamp and raw body joined by dots, using the base64-decoded part of your whsec_ secret as the key. Base64-encode the result and compare it in constant time with each v1 value in the webhook-signature header.

### Why does my webhook signature never match?

Almost always one of three things: the body was parsed and re-serialized before verifying, the whole whsec_ string was used as the key instead of its base64-decoded bytes, or the secret belongs to a different endpoint.

### What is Standard Webhooks?

An open specification for signing webhooks with HMAC-SHA256 and the webhook-id, webhook-timestamp and webhook-signature headers. Ikiform follows it, so any Standard Webhooks library can verify Ikiform deliveries.

### Why reject old timestamps?

A captured request would otherwise stay valid forever. Rejecting anything more than 5 minutes from your clock stops replays of old requests, and Ikiform signs every retry with a fresh timestamp, so real retries still pass.

### What happens to deliveries when I roll the secret?

For 24 hours each delivery is signed with both the old and the new secret, so you can deploy the new one without losing events. After that only the new secret works.

### Should my handler return 401 or 400 for a bad signature?

Either is fine for Ikiform; any status other than 2xx counts as a failure and is retried. Don't return 410, which disables the endpoint immediately.

## More guides

- [Node.js](https://ikiform.com/guides/webhook-signature-verification/nodejs.md): Verify Ikiform webhook signatures in Node.js: raw bodies in Express and Fastify, the SDK, the standardwebhooks package and a tested node:crypto version.
- [Python](https://ikiform.com/guides/webhook-signature-verification/python.md): Verify Ikiform webhook signatures in Flask, FastAPI and Django, with the standardwebhooks package or the standard library, plus a pytest test.
- [Go net/http](https://ikiform.com/guides/webhook-signature-verification/go.md): Verify Ikiform webhook signatures in Go with net/http: read the body once, check it with crypto/hmac, test it, or use the Standard Webhooks library.
- [PHP](https://ikiform.com/guides/webhook-signature-verification/php.md): Verify Ikiform webhook signatures in plain PHP with hash_hmac and hash_equals, and in Laravel with the Standard Webhooks library and a CSRF exception.
- [Ruby](https://ikiform.com/guides/webhook-signature-verification/ruby.md): Verify Ikiform webhook signatures in Sinatra with OpenSSL and a Minitest test, and in Rails with the standardwebhooks gem and request.raw_post.
- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [Contact form on a static site](https://ikiform.com/guides/contact-form-static-site.md): Add a working contact form to a static site with no server: where the POST goes, spam, replies by email, and code for every host and framework.
- [Send a form to your email](https://ikiform.com/guides/send-form-to-email.md): Get every form submission in your inbox without a mail server: why mailto: and PHP mail() fail, and a setup that filters spam and lets you reply.
