> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flowpayroll.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks quickstart

> From nothing to a working, verified webhook receiver in about fifteen minutes: build the receiver, register it, send a test event, then subscribe to real events.

This walks you through receiving your first Flow Payroll event. You need an access token (see [Authentication](/api-reference/authentication)) and somewhere to run a small web server.

<Steps>
  <Step title="Build a receiver">
    A receiver is a URL on your server that accepts an HTTP `POST`. It must do three things, in this order:

    1. **Check the signature**, to be sure the request came from Flow Payroll.
    2. **Store the event**, or put it on a queue.
    3. **Answer quickly** with a success status (any status from `200` to `299`).

    Do the real work (updating your records, sending emails) afterwards, not before answering.

    <CodeGroup>
      ```javascript Node.js theme={null}
      // npm install express
      import express from "express"
      import { createPublicKey, verify } from "node:crypto"

      // The endpoint's publicKey (whpk_...), which you get in step 3.
      const publicKey = createPublicKey({
        key: {
          kty: "OKP",
          crv: "Ed25519",
          x: Buffer.from(process.env.FLOW_WEBHOOK_PUBLIC_KEY.slice("whpk_".length), "base64").toString("base64url"),
        },
        format: "jwk",
      })
      const seen = new Set() // use a database table in production
      const app = express()

      function isGenuine(headers, rawBody) {
        const timestamp = headers["webhook-timestamp"] ?? ""
        if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false

        const message = Buffer.concat([Buffer.from(`${headers["webhook-id"]}.${timestamp}.`, "utf8"), rawBody])
        return (headers["webhook-signature"] ?? "")
          .split(" ")
          .filter((entry) => entry.startsWith("v1a,"))
          .some((entry) => verify(null, message, publicKey, Buffer.from(entry.slice("v1a,".length), "base64")))
      }

      // express.raw keeps the body exactly as sent, which the signature check needs.
      app.post("/webhooks/flow-payroll", express.raw({ type: "application/json" }), (req, res) => {
        if (!isGenuine(req.headers, req.body)) return res.status(400).send("Invalid signature")

        const event = JSON.parse(req.body)
        if (!seen.has(event.id)) {
          seen.add(event.id)
          console.log("Received", event.type, "for organisation", event.organisationId)
        }
        res.sendStatus(204)
      })

      app.listen(3000)
      ```

      ```python Python theme={null}
      # pip install flask cryptography
      import base64
      import json
      import os
      import time

      from cryptography.exceptions import InvalidSignature
      from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
      from flask import Flask, request

      # The endpoint's publicKey (whpk_...), which you get in step 3.
      public_key = Ed25519PublicKey.from_public_bytes(
          base64.b64decode(os.environ["FLOW_WEBHOOK_PUBLIC_KEY"].removeprefix("whpk_"))
      )
      seen = set()  # use a database table in production
      app = Flask(__name__)


      def is_genuine(headers, raw_body):
          timestamp = headers.get("webhook-timestamp", "")
          if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
              return False

          message = f'{headers.get("webhook-id", "")}.{timestamp}.'.encode() + raw_body
          for entry in headers.get("webhook-signature", "").split(" "):
              version, _, signature = entry.partition(",")
              if version != "v1a":
                  continue
              try:
                  public_key.verify(base64.b64decode(signature), message)
                  return True
              except (InvalidSignature, ValueError):
                  continue
          return False


      @app.post("/webhooks/flow-payroll")
      def receive():
          raw_body = request.get_data()
          if not is_genuine(request.headers, raw_body):
              return "Invalid signature", 400

          event = json.loads(raw_body)
          if event["id"] not in seen:
              seen.add(event["id"])
              print("Received", event["type"], "for organisation", event["organisationId"])
          return "", 204


      app.run(port=3000)
      ```

      ```php PHP theme={null}
      <?php
      // Save as receiver.php and run: php -S 0.0.0.0:3000 receiver.php
      // No package needed: sodium is bundled with PHP 7.2 and later.

      function is_genuine(array $headers, string $rawBody): bool
      {
          $timestamp = $headers['webhook-timestamp'] ?? '';
          if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) return false;

          // The endpoint's publicKey (whpk_...), which you get in step 3.
          $publicKey = base64_decode(substr(getenv('FLOW_WEBHOOK_PUBLIC_KEY'), strlen('whpk_')), true);
          if ($publicKey === false || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) return false;

          $message = ($headers['webhook-id'] ?? '') . ".{$timestamp}." . $rawBody;
          foreach (explode(' ', $headers['webhook-signature'] ?? '') as $entry) {
              [$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
              if ($version !== 'v1a') continue;
              $signature = base64_decode($signature, true);
              if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) continue;
              if (sodium_crypto_sign_verify_detached($signature, $message, $publicKey)) return true;
          }
          return false;
      }

      // php://input is the body exactly as sent, which the signature check needs.
      $rawBody = file_get_contents('php://input');
      if (!is_genuine(array_change_key_case(getallheaders(), CASE_LOWER), $rawBody)) {
          http_response_code(400);
          exit('Invalid signature');
      }

      $event = json_decode($rawBody, true);
      $seenFile = __DIR__ . '/seen-events.txt'; // use a database table in production
      $seen = is_file($seenFile) ? file($seenFile, FILE_IGNORE_NEW_LINES) : [];
      if (!in_array($event['id'], $seen, true)) {
          file_put_contents($seenFile, $event['id'] . PHP_EOL, FILE_APPEND | LOCK_EX);
          error_log("Received {$event['type']} for organisation {$event['organisationId']}");
      }
      http_response_code(204);
      ```
    </CodeGroup>

    This receiver checks the `v1a` signature with your endpoint's public key, which is the recommended way: the receiver holds nothing that could sign a fake delivery. To check the `v1` signature with the signing secret instead, see [Verify with the signing secret](/api-reference/webhooks/verify-signatures#verify-with-the-signing-secret-v1).

    You do not have the public key yet. You get it in step 3.
  </Step>

  <Step title="Give it a public HTTPS address">
    Flow Payroll can only deliver to a public `https://` address on the standard HTTPS port (443), so it will not reach `localhost` on your machine. While you build, a **tunnelling tool** gives your local server a temporary public address. Point one at port 3000 and it gives you an address such as `https://a1b2c3d4.tunnel.example.com`.

    Your receiver's URL is then `https://a1b2c3d4.tunnel.example.com/webhooks/flow-payroll`.
  </Step>

  <Step title="Register it as an endpoint">
    An **endpoint** is Flow Payroll's record of your receiver: its URL, which events it wants, and the keys its deliveries are signed with.

    ```bash theme={null}
    curl -X POST https://api.sandbox.flowpayroll.ai/v1/webhooks/endpoints \
      -H "X-Auth-Token: $TOKEN" \
      -H "X-Org-Id: $ORGANISATION_ID" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://a1b2c3d4.tunnel.example.com/webhooks/flow-payroll",
        "description": "My first receiver",
        "eventTypes": ["payslip.finalised"]
      }'
    ```

    The answer contains `endpoint.endpointId` (starting `ep_`), `publicKey` (starting `whpk_`) and `signingSecret` (starting `whsec_`). Copy `publicKey` into `FLOW_WEBHOOK_PUBLIC_KEY` and restart your receiver. The public key is not a secret, and you can read it again at any time from `GET /endpoints/{endpointId}/secrets`.

    <Warning>
      The signing secret is shown **only once**. This receiver does not need it, but store it in your secrets manager if you might check `v1` signatures later. If you lose it, [rotate](/api-reference/webhooks/verify-signatures#rotate-a-signing-secret) to get a new one.
    </Warning>
  </Step>

  <Step title="Send a test event">
    ```bash theme={null}
    curl -X POST https://api.sandbox.flowpayroll.ai/v1/webhooks/endpoints/$ENDPOINT_ID/test-deliveries \
      -H "X-Auth-Token: $TOKEN" \
      -H "X-Org-Id: $ORGANISATION_ID"
    ```

    Your receiver logs `Received webhook.test`, and the answer to your call says `"succeeded": true` with the status your receiver gave. If it says `false`, the `statusCode` tells you why: `400` usually means the public key in your receiver is not this endpoint's.
  </Step>

  <Step title="Receive a real event">
    Finalise a payslip in sandbox. Within seconds your receiver logs `Received payslip.finalised`. To receive more kinds of event, add them:

    ```bash theme={null}
    curl -X POST https://api.sandbox.flowpayroll.ai/v1/webhooks/endpoints/$ENDPOINT_ID/event-types \
      -H "X-Auth-Token: $TOKEN" \
      -H "X-Org-Id: $ORGANISATION_ID" \
      -H "Content-Type: application/json" \
      -d '{ "eventTypes": ["employee.*", "p60.issued"] }'
    ```

    `employee.*` means every employee event, including ones we add later. [Event types](/api-reference/webhooks/event-types) lists them all.
  </Step>
</Steps>

## Before you go live

* [ ] Your receiver runs on your own server, not a tunnel, at a stable `https://` address.
* [ ] It verifies every delivery and rejects failures with `400`. See [Verify signatures](/api-reference/webhooks/verify-signatures).
* [ ] It records each event's `id` in a database and skips repeats. See [Handle duplicates](/api-reference/webhooks/best-practices#handle-duplicates).
* [ ] It answers within 15 seconds and does slow work afterwards. See [Answer quickly](/api-reference/webhooks/best-practices#answer-quickly).
* [ ] The live endpoint is created with your **live** token, and your receiver has its public key (and its signing secret is in your secrets manager, if you use it). Sandbox and live endpoints, keys and secrets are separate.
* [ ] Someone is told when deliveries fail. Check the [delivery log](/api-reference/webhooks/replay-and-recovery#the-delivery-log), or alert on your own receiver's errors.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.