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

# Push subscriptions

> Register a browser for web push against a Senderz profile.

Web push in Senderz is a per-browser subscription attached to a profile. A browser produces a subscription with the W3C Push API, and you register it here so campaigns, flows and test sends can reach it.

Two endpoints are involved, and they authenticate differently.

| Method and path                     | Auth                                                                 |
| ----------------------------------- | -------------------------------------------------------------------- |
| `GET /api/v1/push/vapid-public-key` | None. Safe to call from a browser.                                   |
| `POST /api/v1/push/subscriptions`   | [`X-Sender-Tenant` + `X-API-Key`](/en/api-reference/authentication). |

<Info>
  You only need these endpoints if you are building your own storefront or app
  front end. The Senderz storefront bundle already performs both calls for
  popups that collect push opt-in.
</Info>

<Warning>
  The subscribe endpoint is not open to arbitrary browser origins. Only the
  VAPID key lookup responds to a cross-origin request from any site; the
  subscribe call is subject to the normal CORS allowlist. Collect the
  subscription in the browser, then post it to your own backend and register it
  from there so the API key is never exposed to the page.
</Warning>

## Get the VAPID public key

```http theme={"system"}
GET /api/v1/push/vapid-public-key
```

Returns the platform VAPID public key. It is the value you pass as `applicationServerKey` when calling `pushManager.subscribe()`.

The key is platform-wide, not per workspace, so this endpoint takes no input and needs no credentials. Sending headers or a query string changes nothing, and the route is `GET` only. Fetch it once at startup and cache it.

### Response

<ResponseField name="publicKey" type="string | null">
  The base64url VAPID public key, or `null` when web push is not configured on
  the platform.
</ResponseField>

<ResponseField name="configured" type="boolean">
  `true` when a key is available. When `false`, subscriptions are still
  recorded but no notification can be delivered, so hide your opt-in prompt.
</ResponseField>

### Example

<CodeGroup>
  ```bash cURL theme={"system"}
  curl https://api.senderz.app/api/v1/push/vapid-public-key
  ```

  ```javascript Browser theme={"system"}
  const res = await fetch(
    "https://api.senderz.app/api/v1/push/vapid-public-key",
  );
  const { data } = await res.json();
  if (!data.configured) return;

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: data.publicKey,
  });
  ```
</CodeGroup>

```json 200 OK theme={"system"}
{
  "success": true,
  "data": {
    "publicKey": "BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW2NieyAfRzvr6jE-ehNH-AjcUcc7Le7nLfWp8",
    "configured": true
  }
}
```

```json 200 OK (push not configured) theme={"system"}
{
  "success": true,
  "data": {
    "publicKey": null,
    "configured": false
  }
}
```

## Register a subscription

```http theme={"system"}
POST /api/v1/push/subscriptions
```

Attaches a browser subscription to an existing profile. Returns `200 OK`, not `201`.

The profile must already exist. Create it first with [`POST /public/profiles`](/en/api-reference/profiles) or [`PUT /public/profiles`](/en/api-reference/profiles), then pass its id here.

### Headers

<ParamField header="X-Sender-Tenant" type="string" required>
  Workspace slug or UUID.
</ParamField>

<ParamField header="X-API-Key" type="string" required>
  Your workspace API key. The key decides which workspace the subscription is
  written to. The header value is never trusted on its own.
</ParamField>

### Body

The body mirrors the browser's `PushSubscription` object plus the profile id and optional device metadata.

<ParamField body="contactId" type="string" required>
  UUID of the profile this browser belongs to. A profile that does not exist in
  the workspace returns `404`.
</ParamField>

<ParamField body="endpoint" type="string" required>
  The push service endpoint URL from `subscription.endpoint`. Non-empty,
  maximum 2048 characters.
</ParamField>

<ParamField body="keys" type="object" required>
  The encryption key pair from `subscription.getKey()`. The object as a whole is
  required. Omitting it, or sending `null`, returns `400`, because a
  subscription without keys can never be delivered to.

  <Expandable title="keys">
    <ParamField body="keys.p256dh" type="string" required>
      Base64url `p256dh` key. Non-empty, maximum 255 characters.
    </ParamField>

    <ParamField body="keys.auth" type="string" required>
      Base64url `auth` secret. Non-empty, maximum 64 characters.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="userAgent" type="string">
  Optional user agent string, maximum 500 characters. Stored for support and
  never returned by this endpoint.
</ParamField>

<ParamField body="browser" type="string">
  Optional browser label, maximum 64 characters. Free text, for example
  `chrome`.
</ParamField>

<ParamField body="platform" type="string">
  Optional platform label, maximum 64 characters. Free text, for example
  `macos`.
</ParamField>

Unknown properties are rejected, both at the top level and inside `keys`. Sending the raw browser subscription object unmodified will fail if it carries extra fields such as `expirationTime`, so pick out `endpoint` and `keys` explicitly.

### Response

<ResponseField name="id" type="string">
  Subscription UUID.
</ResponseField>

<ResponseField name="contactId" type="string">
  The profile the subscription is attached to.
</ResponseField>

<ResponseField name="endpointHash" type="string">
  SHA-256 hex digest of the endpoint URL, 64 characters. This is the stable
  identity of the browser. The endpoint URL itself is never returned.
</ResponseField>

<ResponseField name="browser" type="string | null">
  The browser label you sent, or `null`.
</ResponseField>

<ResponseField name="platform" type="string | null">
  The platform label you sent, or `null`.
</ResponseField>

<ResponseField name="createdAt" type="string">
  ISO 8601 timestamp of first registration.
</ResponseField>

<ResponseField name="lastSeenAt" type="string">
  ISO 8601 timestamp, refreshed on every registration of this endpoint.
</ResponseField>

<ResponseField name="revokedAt" type="string | null">
  ISO 8601 timestamp when the subscription was revoked, or `null` while live. A
  successful registration always returns `null` here, including when it revived
  a previously revoked row.
</ResponseField>

<ResponseField name="revokeReason" type="string | null">
  Why the subscription was revoked, or `null` while live.
</ResponseField>

### Example

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.senderz.app/api/v1/push/subscriptions \
    -H "X-Sender-Tenant: acme-store" \
    -H "X-API-Key: $SENDERZ_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "contactId": "a1b2c3d4-e5f6-4890-ab12-cd34ef567890",
      "endpoint": "https://fcm.googleapis.com/fcm/send/cAbc123KpQ",
      "keys": {
        "p256dh": "BNNl4kT0r4dKVwm2T6PdOZ0r0c9D8R7Yw0qV3JfX3CGq8kHRrM4uM9JZpRQa3uV-ZmuKuq6dD3uM9rE0z4ZHkb0",
        "auth": "tBHItJI5svbpez7KI4CCXg"
      },
      "browser": "chrome",
      "platform": "macos"
    }'
  ```

  ```javascript Node.js theme={"system"}
  const json = subscription.toJSON();

  const res = await fetch("https://api.senderz.app/api/v1/push/subscriptions", {
    method: "POST",
    headers: {
      "X-Sender-Tenant": process.env.SENDERZ_WORKSPACE,
      "X-API-Key": process.env.SENDERZ_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      contactId,
      endpoint: json.endpoint,
      keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
      browser: "chrome",
      platform: "macos",
    }),
  });
  const { data } = await res.json();
  ```

  ```python Python theme={"system"}
  import requests

  res = requests.post(
      "https://api.senderz.app/api/v1/push/subscriptions",
      headers={
          "X-Sender-Tenant": WORKSPACE,
          "X-API-Key": API_KEY,
          "Content-Type": "application/json",
      },
      json={
          "contactId": contact_id,
          "endpoint": subscription["endpoint"],
          "keys": {
              "p256dh": subscription["keys"]["p256dh"],
              "auth": subscription["keys"]["auth"],
          },
          "browser": "chrome",
          "platform": "macos",
      },
  )
  data = res.json()["data"]
  ```
</CodeGroup>

```json 200 OK theme={"system"}
{
  "success": true,
  "data": {
    "id": "b2c3d4e5-f6a7-4901-bc23-de45f6789012",
    "contactId": "a1b2c3d4-e5f6-4890-ab12-cd34ef567890",
    "endpointHash": "9b74c9897bac770ffc029102a200c5de6e5a1ea0a9a5b0f9c17f4f38f36d92b1",
    "browser": "chrome",
    "platform": "macos",
    "createdAt": "2026-08-26T10:00:00.000Z",
    "lastSeenAt": "2026-08-26T10:00:00.000Z",
    "revokedAt": null,
    "revokeReason": null
  }
}
```

## What registering does

A successful call does more than store a row. In one transaction it:

<Steps>
  <Step title="Upserts the subscription">
    Identity is the endpoint, not the request. Posting the same `endpoint`
    again updates the keys and device labels, clears `revokedAt` and
    `revokeReason`, and refreshes `lastSeenAt` on the existing row rather than
    creating a second one. Re-registering on every page load is safe.
  </Step>

  <Step title="Opts the profile in to push">
    Sets the profile's push consent to `subscribed`, stamps the consent
    timestamp and records the consent source as `storefront_popup`.
  </Step>

  <Step title="Writes a consent record">
    Appends an entry to the profile's consent history with the caller IP and
    user agent as evidence.
  </Step>

  <Step title="Lifts suppression for that browser">
    Removes any push suppression held against this endpoint, so a browser that
    previously went away can opt back in.
  </Step>
</Steps>

<Note>
  Push suppression is per browser, keyed on `endpointHash`, not per profile. One
  browser going stale does not unsubscribe the profile while another live
  browser remains.
</Note>

## Errors

| Status | When                                                                                                                                                                                                                                        |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Validation failed: `contactId` not a UUID, `endpoint` missing or blank or over 2048 characters, `keys` absent or `null` or not an object, `p256dh` or `auth` blank or oversized, or any unknown property at the top level or inside `keys`. |
| `401`  | `X-Sender-Tenant` or `X-API-Key` missing (`Missing tenant or API key`), or the pair did not resolve (`Invalid API key`). A workspace session token is not a substitute for the key pair on this endpoint.                                   |
| `404`  | `contact not found`. The profile id does not exist in the workspace the key resolved to.                                                                                                                                                    |
| `429`  | Rate limited.                                                                                                                                                                                                                               |

```json 404 Not Found theme={"system"}
{
  "success": false,
  "message": "contact not found"
}
```

<Tip>
  Validation runs before the credentials are checked, so a malformed body
  returns `400` even when the API key is wrong. Fix the payload first, then the
  headers.
</Tip>

## Managing subscriptions afterwards

Listing, revoking and test-sending a push notification for a profile are dashboard operations and are not part of the API-key surface. To stop reaching a profile on push, update its push consent through [Profiles](/en/api-reference/profiles), or let the recipient opt out from the preference centre.

Delivery outcomes for push sends appear alongside every other channel in the message delivery status endpoints on [Messages](/en/api-reference/messages).
