> ## 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.

# Best practices

> Build a receiver that never double-processes an event, never times out, and keeps working as new events are added.

## Handle duplicates

Delivery is **at least once**. A network blip after your server answered, a retry or a resend can all bring the same event again. Every copy has the same `webhook-id`.

Record each `webhook-id` you have processed and skip any you have seen before. A unique index on the id in your database does this in one step. Keep ids for at least as long as the [retry schedule](/api-reference/webhooks/delivery-and-retries#retry-schedule) runs, and longer if you replay older events.

## Answer quickly

Answer `2xx` as soon as you have checked the signature and stored the event, then do the real work afterwards, for example from a queue. A receiver that updates payroll records before answering can pass the 15-second limit under load, and a timed-out delivery is retried even though your work succeeded.

## Process in the background

Pay runs happen in bursts. Finalising a pay run for 5,000 employees sends 5,000 `payslip.finalised` events within minutes. Put deliveries on a queue and process them at a rate your system handles. If your server needs to slow Flow Payroll down, answer `429` or `503` with a `Retry-After` header (`502` and `504` work too): deliveries slow down for you and nothing is lost.

## Don't rely on order

Unless an endpoint is set to `"ordering": "Fifo"`, events can arrive in a different order from the one they happened in, and a retry or a replay can bring an older event after a newer one. See [Ordering](/api-reference/webhooks/delivery-and-retries#ordering).

Wherever you store state from an event, store the event's `timestamp` with it. When another event for the same record arrives, compare the two and skip the event if it is older than the one you already applied, so older data never overwrites newer data. `timestamp` is when the change happened, not when it was sent, so it stays the same on every retry and replay.

## Treat a Full payload as a snapshot

A `Full` payload is the record as it was when the event happened, not as it is now. A retry or a replay sends that same snapshot again, however much has changed since. When you need the current state, fetch `metadata.resourceUrl` with your own credentials: it returns the record as it is at the moment you call it.

## Start leaver workflows from the leaver event

To remove a leaver's access to your systems, or to start any other leaver process, act on `employee.leaver.recorded`. It is sent as soon as an employee is marked as leaving, and carries the leaving date, which can be in the future: schedule the change for that date rather than making it straight away. If the leaver marking is undone, `employee.leaver.reverted` follows, so reverse what you did.

`p45.issued` comes later. It is sent only when the pay run with the leaver's final payslip is finalised and the P45 is produced, which can be days or weeks after the leaver was recorded, so it is the wrong trigger for removing access. Use it to collect the P45 from `metadata.documentUrl`. If a leaver is reverted after their P45 was issued, `p45.revoked` withdraws it.

## Fetch documents with your own credentials

A payslip, P45 or P60 event carries `metadata.documentUrl`. Download the PDF from it when you need the file, authenticating as you do for any other API call. Don't forward the event to someone who cannot sign in: the link only works for a caller with access to the organisation.

## Verify every delivery

Check the signature and the timestamp on every request before trusting its body. See [Verify signatures](/api-reference/webhooks/verify-signatures). Reject anything that fails with `400`, and never log or store the unverified body as if it came from Flow Payroll.

## Keep your secret safe

Verify with the public key where you can: it cannot sign, so a leak of your receiver's configuration cannot be used to forge a delivery. If you verify with the signing secret, store it in a secrets manager, not in code or a repository. Rotate it straight away if you think it has leaked: rotation keeps both secrets signing for 24 hours, so your receiver never misses a delivery while you switch.

## Protect what you receive

`Full` events carry employees' personal and pay details: see [Personal data in payloads](/api-reference/webhooks/event-types#personal-data-in-payloads).

* Store deliveries as securely as the rest of your payroll records, and limit who can read them.
* Keep them only as long as you need them.
* Log the `webhook-id` and `X-Flow-Delivery-Id`, not the whole body.
* To hold less, choose `Minimal` payloads and fetch only the fields you use.

## Expect new events and fields

Subscribe to exact event types, so a type added later does not reach code that cannot handle it. Ignore fields you do not recognise. Both are how Flow Payroll adds to events without a new version.

## Use HTTPS with a valid certificate

Endpoints must be `https://` on port 443 with a certificate issued by a public authority. Self-signed certificates, private IP addresses and addresses such as `localhost` are refused. To test locally, use a tunnelling tool; see [Testing webhooks](/api-reference/webhooks/testing).

## Allow-list our sending addresses

If your firewall only accepts known senders, allow these addresses. Every delivery, test send and replay leaves from them, and they do not change.

| Environment | Addresses |
| - | - |
| Production | `52.18.20.191`, `52.212.106.116` |
| Sandbox | `40.180.96.229` |

An allow-list is an extra layer, not a replacement: always verify the signature as well.

## Exempt the route from CSRF protection

Frameworks such as Rails and Django check a CSRF token on every `POST`, which a webhook cannot carry. Exempt your webhook route; the signature check replaces it.

## Make registration retry-safe

Send an `Idempotency-Key` header when you create an endpoint, rotate a secret, start a replay or resend a delivery. If your request times out and you send it again with the same key and the same body within 24 hours, you get the first answer back instead of a second endpoint or a second resend. Use a new key for each new request: reusing one with a different body is a `409` with `idempotency_key_reused`.


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