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

> ## Agent Instructions
> Use Stable documentation by default. Honor an explicit Beta request or a URL under /docs/beta/. If the requested version conflicts with the installed CLI package or API origin, clarify the target before writing integration code.
> Pages under /docs/beta/ document Beta; other product pages document Stable. Keep the CLI package, commands, API origin, and credentials within the selected version. State the documentation version in your answer.
> For MCP search, always pass version: Stable or version: Beta. Unfiltered search mixes both versions. For filesystem reads, keep Beta queries under /beta/ and exclude /beta/ from Stable queries; discover paths before reading them.
> The public docs base is https://photon.codes/docs. Convert MCP page paths to public URLs under that base, preserving /beta/ when present. Read https://photon.codes/docs/skill.md for version selection and https://photon.codes/docs/llms.txt for the version indexes.

# Webhook

> Receive project events over HTTPS and verify them with Standard Webhooks.

Photon sends your project's events as signed HTTP `POST` requests to a URL you
control. Use webhooks for a backend that can accept public HTTPS requests. Use
[WebSocket](https://docs.photon.codes/docs/beta/message-delivery/websocket.md) when your process needs to receive
events without a public HTTP endpoint.

These pages describe how Photon sends events to your application. A successful
webhook response does not mean a message reached its recipient.

## Add a destination

**1. Create an HTTPS receiver**

Create a public HTTPS URL that accepts `POST` requests. Keep the request
body unchanged so you can verify its signature. Photon does not follow
redirects. It also blocks private network destinations, including localhost.

**2. Choose the events to receive**

In the [dashboard](https://dashboard.photon.codes), open your project's
**Webhooks** page and select **Add a destination**. Set the URL, choose a
payload API version, and select the events your application handles.

You can also [create a destination through the API](https://docs.photon.codes/docs/beta/api-client/sdk/projects/create-webhook-destination.md).
Supply an `Idempotency-Key` with the request. Choose a
[selectable API version](https://docs.photon.codes/docs/beta/api-client/sdk/projects/list-webhook-api-versions.md).
Then choose events from that version's
[event catalog](https://docs.photon.codes/docs/beta/api-client/sdk/projects/list-webhook-event-types.md).
`enabledEvents: ["*"]` subscribes to all events; an empty array subscribes
to none. Keep the destination enabled to receive deliveries.

**3. Save the signing secret**

Store the returned `whsec_...` secret in your server's secret manager, for
example as `PHOTON_WEBHOOK_SECRET`. It belongs to this destination and is
different from your Photon API key.

Photon returns the secret when you create the destination or rotate its
secret. The API also returns it if you repeat that request with its original
`Idempotency-Key`. Reading a destination does not return its secret. Never
put the secret in browser code, source control, or logs.

## Verify with Standard Webhooks

We recommend the [standardwebhooks](https://www.npmjs.com/package/standardwebhooks)
package for JavaScript and TypeScript. It verifies Photon's signatures without
custom options and supports secret rotation. Other languages can use the
[Standard Webhooks libraries](https://www.standardwebhooks.com/).

```bash npm theme={null}
npm install standardwebhooks
```

```bash pnpm theme={null}
pnpm add standardwebhooks
```

```bash yarn theme={null}
yarn add standardwebhooks
```

```bash bun theme={null}
bun add standardwebhooks
```

This server-side function accepts a Fetch API `Request`. It verifies the request
and returns the parsed JSON body. Call it from your HTTP route before you process
the event.

```ts theme={null}
import { Webhook } from "standardwebhooks";

const secret = process.env.PHOTON_WEBHOOK_SECRET;
if (!secret) throw new Error("PHOTON_WEBHOOK_SECRET is required");

const verifier = new Webhook(secret);

export async function verifyPhotonWebhook(request: Request) {
  const rawBody = await request.text();
  return verifier.verify(rawBody, {
    "webhook-id": request.headers.get("webhook-id") ?? "",
    "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
    "webhook-signature": request.headers.get("webhook-signature") ?? "",
  });
}
```

If verification throws an error, return `400`. Do not process the event.
The library checks the signature and rejects timestamps more than five minutes
in the past or future. Keep your server clock synchronized.

> [!WARNING]
>
> Verify the original body, not `JSON.stringify()` of a parsed object. Read it
> before JSON middleware reads or changes it. Pass the full signing secret
> to `new Webhook(secret)`. Do not use `{ format: "raw" }` for Photon secrets.

Verification confirms that Photon signed the request. It does not check the
event schema or prevent you from processing the same event twice. Use the
destination's event catalog to interpret the body after verification.

### Headers and payload

Each request has `Content-Type: application/json` and these headers:

| Header | Meaning |
| - | - |
| `webhook-id` | The public event's `id`. It does not change on retries. Use it to detect duplicates. |
| `webhook-timestamp` | The attempt's Unix timestamp in seconds. Photon updates it on each retry. |
| `webhook-signature` | One or more space-separated `v1,<base64>` signatures. |

The signature is HMAC-SHA256 over `webhook-id.webhook-timestamp.rawBody`, using
the Base64-decoded signing key. The library handles this format for you.

The body is the public event JSON for the selected API version. Photon does not
wrap it in another delivery object. Webhooks and WebSocket deliver the same
public event object for the same event and API version.

## Handle duplicate deliveries

Save the verified event to storage that survives process restarts. Then return
any `2xx` status. For ordinary event notifications, an empty `204` response is
enough. Queue slow work instead of keeping the request open.

Photon can deliver the same event more than once. For example, a lost response
can cause Photon to retry an event you already processed. Use `webhook-id` to
detect duplicates.

Save the event ID and its queued work in one atomic operation, such as a database
transaction. Both must succeed or fail together. This prevents a crash from
marking an event complete without saving its work. Return `2xx` for a duplicate
you already accepted. If you cannot save the event temporarily, return `503` so
Photon can retry.

## Retries and ordering

| Receiver outcome | Delivery behavior |
| - | - |
| Any `2xx` | Delivery succeeds. |
| `404`, `408`, `429`, or `5xx` | Photon waits, then retries. |
| Connection failure or timeout | Photon waits, then retries. |
| `410 Gone` | Photon disables the destination. |
| Other `4xx`, or any redirect (`3xx`) | Photon stops this delivery. It does not retry or follow the redirect. |

Photon increases retry delays and adds random variation to avoid simultaneous
retries. A valid `Retry-After` on a retryable response can delay the next attempt,
within the retry limits.

The default limits are four days from the first failure and 20 failed attempts.
Delivery can stop earlier. Photon disables a destination when its delivery
failures reach a retry limit. Fix the receiver before you re-enable the destination.

Photon delivers events in order within each destination and event type. A
retrying event holds up later events of that type for that destination. Other
event types and destinations can continue independently. Do not assume that
events arrive in order across different event types or destinations.

Normal deliveries have a 20-second timeout by default. Events that request a
synchronous response have a two-second initial timeout by default. For those
events, return the response that the event's contract requires before that
deadline. An HTTP response to a webhook is not a new chat message. Send chat
replies through the messaging API.

## Rotate the secret

[Rotate the signing secret](https://docs.photon.codes/docs/beta/api-client/sdk/projects/rotate-webhook-signing-secret.md)
and save the new value securely. During rotation, the old and new keys can be
valid at the same time. Photon sends signatures for both in the same header.
`standardwebhooks` accepts the signature that matches your configured secret.
Update your receiver to use the new secret before the old key expires.

## Test your receiver

Use a public HTTPS tunnel to reach a local receiver, or use
[WebSocket](https://docs.photon.codes/docs/beta/message-delivery/websocket.md) without a tunnel. Trigger an event
you subscribed to and confirm that your application verifies and accepts it.
Check the dashboard's delivery logs when no request arrives or a delivery fails.

When verification fails, check the destination's secret, all three headers,
your server clock, and whether middleware changed the raw body. Do not disable
verification to make a test pass.


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