FP FormPing Documentation
Open Dashboard

05 — Integrations · Webhooks

Forward submissions to your own backend.

Every submission can be POSTed to a URL you control, signed with HMAC-SHA256 so you can trust it came from FormPing.

Setup

  1. Open the dashboard and pick the token you want to forward.
  2. Open Webhooks in the token's settings.
  3. Paste your endpoint URL (HTTPS only) and a webhook secret — generate a long random string.
  4. Save. The next submission to this token will hit your endpoint.
!

Treat the webhook secret like a password. Never commit it to a repo. Store it as an environment variable on your server.

The payload

Webhooks are sent as POST requests with JSON bodies. Sample payload:

{
  "id": "msg_01HX2K9Z0M8YQ5FN7GA6V3T1WB",
  "token_id": "tok_01HX0001",
  "received_at": "2026-04-08T10:14:32.117Z",
  "fields": {
    "Name": "Ada Lovelace",
    "Email": "[email protected]",
    "Message": "Hello from the docs!"
  },
  "score": null,
  "meta": {
    "ip_country": "CH",
    "user_agent": "Mozilla/5.0 ..."
  }
}

Headers

Verifying the signature

Always verify the signature before trusting the payload. Compute HMAC-SHA256 of the raw request body (before any JSON parsing) using your webhook secret, and compare it to the value in X-FormPing-Signature.

// Express + Node 18+
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const SECRET = process.env.FORMPING_WEBHOOK_SECRET;

// IMPORTANT: capture the raw body before json parsing
app.post(
  '/webhooks/formping',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.header('x-formping-signature') || '';
    const expected =
      'sha256=' +
      crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send('invalid signature');
    }

    const payload = JSON.parse(req.body.toString('utf8'));
    console.log('new submission:', payload.id);
    res.status(200).send('ok');
  },
);

app.listen(3000);
<?php
// PHP 8+
$secret = getenv('FORMPING_WEBHOOK_SECRET');
$body   = file_get_contents('php://input');
$sig    = $_SERVER['HTTP_X_FORMPING_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);

if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    echo 'invalid signature';
    exit;
}

$payload = json_decode($body, true);
error_log('new submission: ' . $payload['id']);

http_response_code(200);
echo 'ok';
# Flask
import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["FORMPING_WEBHOOK_SECRET"].encode()


@app.post("/webhooks/formping")
def formping_webhook():
    body = request.get_data()  # raw bytes — do not use request.json
    sig = request.headers.get("X-FormPing-Signature", "")

    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, sig):
        abort(401, "invalid signature")

    payload = request.get_json()
    print("new submission:", payload["id"])
    return "ok", 200
i

Always use a constant-time comparison (timingSafeEqual, hash_equals, hmac.compare_digest). A naive == check is vulnerable to timing attacks.

Retries

If your endpoint returns anything other than a 2xx status, FormPing retries with exponential backoff: roughly 30s, 2min, 10min. After three failures the delivery is dropped — but the submission itself is still safe in your inbox.

Your endpoint should respond as fast as possible (under a few seconds) and do any heavy work asynchronously. Long-running handlers will time out and trigger unnecessary retries.

Idempotency

Use the X-FormPing-Delivery header (a UUID) as an idempotency key in your database. If the same delivery hits you twice — for instance because your 200 response got lost in transit — you can safely ignore the duplicate.

Best practices