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

# Notifications

> Subscribe to account events and receive them as signed HTTP callbacks or as email, in real time.

Notifications let your org subscribe to account events — an API key revoked, a member added, your balance running low — and receive them the moment they happen, instead of polling the API or the dashboard.

Each event is sent to one or more **destinations**. A destination is either a **webhook endpoint** — a URL of yours, which receives a signed HTTP `POST` — or an **email recipient**, an address that receives a readable message. Both are configured through the same API and share one delivery log.

Webhook deliveries are signed with an HMAC so you can verify they came from Mesh, retried on failure with exponential backoff, and logged so you can inspect or manually redeliver any attempt.

## Setting up a webhook endpoint

<Steps>
  <Step title="Register your endpoint">
    ```bash theme={null}
    curl https://api.meshapi.ai/alerts/channels \
      -H "Authorization: Bearer YOUR_SESSION_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "channel_type": "webhook",
        "destination": "https://example.com/webhooks/mesh",
        "is_enabled": true
      }'
    ```

    **Response:**

    ```json theme={null}
    {
      "id": "9c1b1e3a-...",
      "channel_type": "webhook",
      "destination": "https://example.com/webhooks/mesh",
      "is_enabled": true,
      "signing_secret": "whsec_9f2a...b7c1"
    }
    ```

    <Warning>
      `signing_secret` is returned **exactly once**, on creation (and again on rotation — see [Rotating your signing secret](#rotating-your-signing-secret)). No `GET` ever returns it. Store it in your own secret manager immediately; if you lose it, rotate.
    </Warning>

    `destination` must be a publicly resolvable `http(s)` URL — not `localhost`, and not an address that resolves to a private, loopback, or link-local range. This is checked when you register or update the endpoint, and again before every delivery attempt, so an endpoint that later starts resolving to an internal address stops receiving deliveries rather than silently forwarding them there.
  </Step>

  <Step title="Subscribe to event types">
    Each subscription is one `(event type → your endpoint)` pair. Create one policy per event type you want:

    ```bash theme={null}
    curl https://api.meshapi.ai/alerts/policies \
      -H "Authorization: Bearer YOUR_SESSION_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "channel_id": "9c1b1e3a-...",
        "alert_type": "api_key.revoked",
        "is_enabled": true
      }'
    ```

    Some events are threshold-based. `balance.low` takes a USD floor:

    ```json theme={null}
    {
      "channel_id": "9c1b1e3a-...",
      "alert_type": "balance.low",
      "threshold_usd": 10.00
    }
    ```

    `spend_cap.approaching` and `rate_limit.threshold` take a percentage instead:

    ```json theme={null}
    {
      "channel_id": "9c1b1e3a-...",
      "alert_type": "spend_cap.approaching",
      "threshold_pct": 80
    }
    ```

    Every other event type takes no threshold — pass neither field. See [Available events](#available-events) for which is which; the API rejects a threshold supplied for a no-threshold event, and rejects a missing one for a threshold event.
  </Step>

  <Step title="Verify deliveries">
    Once subscribed, matching events start arriving at your endpoint as signed `POST` requests. See [Verifying webhook signatures](#verifying-webhook-signatures) below before you trust any payload.
  </Step>
</Steps>

Only org owners/admins can create, update, or delete channels and policies; any org member can read them. Managing channels and policies:

| Endpoint                                   | Behavior                                                                  |
| ------------------------------------------ | ------------------------------------------------------------------------- |
| `GET /alerts`                              | List this org's channels and policies                                     |
| `POST /alerts/channels`                    | Register an endpoint. Returns `signing_secret` once                       |
| `PATCH /alerts/channels/{id}`              | Update the URL or enable/disable                                          |
| `DELETE /alerts/channels/{id}`             | Remove the endpoint — also deletes its subscriptions and delivery history |
| `POST /alerts/channels/{id}/rotate-secret` | Mint a new signing secret                                                 |
| `POST /alerts/policies`                    | Subscribe the endpoint to an event type                                   |
| `PATCH /alerts/policies/{id}`              | Change the threshold, target endpoint, or enabled state                   |
| `DELETE /alerts/policies/{id}`             | Unsubscribe                                                               |

## Sending events to an email address

A destination does not have to be a service. An **email recipient** receives the same events as a readable message — no receiver to build, no signature to verify.

<Steps>
  <Step title="Add the address">
    ```bash theme={null}
    curl https://api.meshapi.ai/alerts/channels \
      -H "Authorization: Bearer YOUR_SESSION_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "channel_type": "email",
        "destination": "ops@yourcompany.com"
      }'
    ```

    Org owners and admins only, same as a webhook endpoint.
  </Step>

  <Step title="Ask us to send the confirmation">
    ```bash theme={null}
    curl -X POST https://api.meshapi.ai/alerts/channels/CHANNEL_ID/verify \
      -H "Authorization: Bearer YOUR_SESSION_TOKEN"
    ```

    We mail a confirmation link to that address. The link is **single-use and
    expires in 24 hours**; re-requesting mints a new one and voids the previous.

    The token is never returned to you — only the recipient can confirm. That is
    the point: it means nobody can sign a colleague up for mail they did not
    agree to.
  </Step>

  <Step title="The recipient confirms">
    They follow the link. Until they do, `verified_at` on the channel stays
    `null` and **no events from this catalogue are delivered to that address.**

    Changing a channel's `destination` clears the confirmation — consent belongs
    to the address, not to the record — so a re-pointed channel must confirm
    again.
  </Step>
</Steps>

Read the state back from `GET /alerts`, which lists every channel:

```json theme={null}
{
  "id": "9c1b1e3a-...",
  "channel_type": "email",
  "destination": "ops@yourcompany.com",
  "is_enabled": true,
  "verified_at": "2026-08-15T09:30:00Z"
}
```

<Note>
  **Email recipients cannot take threshold events yet** — `balance.low`,
  `spend_cap.approaching` and `rate_limit.threshold` each need a value you choose,
  and that is configured per webhook endpoint. Every other event in the catalogue
  can go to an address.
</Note>

Email deliveries appear in the same delivery log as webhooks, described below,
and are retried the same way.

## Available events

| Event                     | Fires when                                                                         | Threshold                         |
| ------------------------- | ---------------------------------------------------------------------------------- | --------------------------------- |
| `api_key.created`         | A new API key is created in your org                                               | —                                 |
| `api_key.revoked`         | An API key is revoked                                                              | —                                 |
| `member.added`            | A member joins your org                                                            | —                                 |
| `member.removed`          | A member is removed from (or leaves) your org                                      | —                                 |
| `balance.low`             | Your balance crosses below a USD floor you configure                               | `threshold_usd` (required, > 0)   |
| `balance.credited`        | A payment or manual credit lands on your balance                                   | —                                 |
| `auto_recharge.triggered` | An AutoPay recharge is **scheduled** — no money has moved yet                      | —                                 |
| `auto_recharge.succeeded` | An AutoPay charge clears and the credit lands on your balance                      | —                                 |
| `auto_recharge.failed`    | An AutoPay recharge attempt fails                                                  | —                                 |
| `balance.depleted`        | Your balance crosses zero. Fires once per crossing, not per request                | —                                 |
| `payment.succeeded`       | A payment is captured and credited. A fully-covering coupon does **not** fire this | —                                 |
| `spend_cap.approaching`   | A key's spend crosses a percentage of its configured spend cap                     | `threshold_pct` (required, 1–100) |
| `spend_cap.hit`           | A request is rejected because a spend cap has been reached, at any scope           | —                                 |
| `rate_limit.threshold`    | A request rate crosses a percentage of an RPM/RPD limit, at any scope              | `threshold_pct` (required, 1–100) |
| `provider_key.added`      | A BYOK provider key is registered                                                  | —                                 |
| `provider_key.removed`    | A BYOK provider key is deleted                                                     | —                                 |
| `batch.completed`         | A batch job finishes successfully                                                  | —                                 |
| `batch.failed`            | A batch job fails or expires. A batch you **cancelled** does not fire this         | —                                 |
| `video.completed`         | A video generation task finishes and the output is ready                           | —                                 |
| `video.failed`            | A video generation task fails                                                      | —                                 |

Every delivery body is a JSON envelope:

```json theme={null}
{
  "id": "evt_01j...",
  "type": "api_key.revoked",
  "created_at": "2026-08-03T10:04:11Z",
  "org_id": "5e3f2b10-...",
  "data": {
    "key_id": "3a7c9e21-...",
    "label": "prod-key",
    "masked_key": "rsk_...9f2a"
  }
}
```

`data` for each event:

| Event                     | `data` fields                                                                                                     |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `api_key.created`         | `key_id`, `label`, `masked_key`                                                                                   |
| `api_key.revoked`         | `key_id`, `label`, `masked_key`                                                                                   |
| `member.added`            | `user_id`, `role`                                                                                                 |
| `member.removed`          | `user_id`, `role`                                                                                                 |
| `balance.low`             | `balance_usd`                                                                                                     |
| `balance.credited`        | `amount_usd`, `balance_after`, `payment_event_id`, `source` (`payment`, `auto_recharge`, `admin_grant`, `coupon`) |
| `auto_recharge.triggered` | `amount_usd`, `scheduled_payment_id`                                                                              |
| `auto_recharge.succeeded` | `payment_event_id`, `payment_id`, `amount_usd`, `current_balance_usd`, `recovered`                                |
| `auto_recharge.failed`    | `reason`, `amount_usd`                                                                                            |
| `balance.depleted`        | `balance_usd`, `balance_before`                                                                                   |
| `payment.succeeded`       | `payment_event_id`, `payment_id`, `provider`, `currency`, `amount_usd`                                            |
| `spend_cap.approaching`   | `scope`, `scope_id`, `key_id`, `spent_usd`, `cap_usd`                                                             |
| `spend_cap.hit`           | `scope`, `scope_id`, `key_id`, `cap_usd`, `spent_usd`                                                             |
| `rate_limit.threshold`    | `scope`, `scope_id`, `key_id`, `limit_type`, `limit`                                                              |
| `provider_key.added`      | `pk_id`, `provider`, `label`                                                                                      |
| `provider_key.removed`    | `pk_id`, `provider`, `label`                                                                                      |
| `batch.completed`         | `batch_id`, `status`, `model`, `provider`, `output_file_id`                                                       |
| `batch.failed`            | `batch_id`, `status`, `model`, `provider`, `output_file_id` (`null`)                                              |
| `video.completed`         | `task_id`, `status`, `model`, `provider`, `video_url`, `error_code` (`null`), `error_message` (`null`)            |
| `video.failed`            | `task_id`, `status`, `model`, `provider`, `video_url` (`null`), `error_code`, `error_message`                     |

### Which limit fired: `scope`

Spend caps and rate limits are enforced at five scopes, not just per key. `spend_cap.*` and `rate_limit.threshold` therefore carry a `scope` discriminator and a `scope_id` naming the thing the limit is attached to:

| `scope`       | `scope_id` is       | Also carries          |
| ------------- | ------------------- | --------------------- |
| `key`         | the API key id      | `key_id` (same value) |
| `member`      | the user id         | —                     |
| `team_member` | the user id         | `team_id`             |
| `team`        | the team id         | —                     |
| `org`         | the organization id | —                     |

`key_id` is always present and is `null` for every scope except `key`, so a receiver that filters on it keeps working and gets an explicit "this is not about one key" rather than a missing field.

```json theme={null}
{
  "id": "evt_01j...",
  "type": "spend_cap.hit",
  "created_at": "2026-08-10T09:12:00Z",
  "org_id": "5e3f2b10-...",
  "data": {
    "scope": "org",
    "scope_id": "5e3f2b10-...",
    "key_id": null,
    "cap_usd": "500.0000",
    "spent_usd": "500.1200"
  }
}
```

One request can trip more than one scope — a key cap and an org cap, say. Each scope is debounced independently, so you receive one event per scope rather than a single ambiguous one.

<Note>
  `spend_cap.approaching` is emitted for the `key` scope only. It is computed from the per-key running total, which is the one counter with a before/after delta available at the moment the total changes; the hierarchy counters have no equivalent. `spend_cap.hit` covers all five scopes.
</Note>

A threshold event's `data` carries the values it crossed with (e.g. `spent_usd`/`cap_usd`), not the specific `threshold_pct`/`threshold_usd` you subscribed with — the same event is fanned out to every matching subscription on your endpoint, and each subscription can have its own threshold, so the envelope can't name "the" one that fired. If you have multiple subscriptions to the same event type at different thresholds, use `data` to compute which of yours applied. Each subscribed threshold is debounced on its own, so a ladder (say 50%, 80% and 95% on one key) delivers every rung it crosses rather than only the first.

`data` only ever contains fields your org already has access to — never a plaintext API key or a provider credential.

## Verifying webhook signatures

Every delivery carries these headers:

| Header                   | Value                                                     |
| ------------------------ | --------------------------------------------------------- |
| `Mesh-Event-Id`          | The logical event's id — use this as your idempotency key |
| `Mesh-Delivery-Id`       | This delivery attempt's id                                |
| `Mesh-Event-Type`        | e.g. `api_key.revoked`                                    |
| `Mesh-Webhook-Timestamp` | Unix seconds when this attempt was sent                   |
| `Mesh-Webhook-Signature` | `v1=<hex>` — see below                                    |

The signature is an HMAC-SHA256 of `{timestamp}.{raw_request_body}`, hex-encoded, using your endpoint's signing secret. This is the same construction Stripe uses, so if you already have a verifier for another vendor, the shape will look familiar.

To verify a request:

1. Read the raw request body — **do not** re-serialize a parsed object. Any difference in key order, whitespace, or number formatting changes the bytes and the signature will not match.
2. Recompute the HMAC over `f"{timestamp}.{raw_body}"` using your signing secret.
3. Compare it against the `v1=` term(s) in `Mesh-Webhook-Signature` using a **constant-time** comparison — never `==`. A variable-time comparison leaks the correct signature one byte at a time through timing.
4. Reject the request if `Mesh-Webhook-Timestamp` is more than 5 minutes from your current time. The timestamp is inside the signed string, so an attacker can't replay an old, captured request with a new timestamp — but without this check, an old captured request replayed with its *original* timestamp would still verify.

<Warning>
  During a secret rotation grace period, `Mesh-Webhook-Signature` carries **two** comma-separated `v1=` terms — one for the new secret, one for the old. Accept the request if **either** term verifies, or your endpoint will reject deliveries for the duration of the rotation window.
</Warning>

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import hashlib
    import hmac
    import time

    TOLERANCE_SECONDS = 300  # 5 minutes


    def verify_signature(secret: str, timestamp: str, raw_body: bytes, signature_header: str) -> bool:
        if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
            return False

        signed_payload = f"{timestamp}.".encode() + raw_body
        expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

        # One v1=<hex> term per active secret — two during a rotation grace
        # window. Accept if ANY term matches.
        candidates = [
            term.split("=", 1)[1] for term in signature_header.split(",") if term.startswith("v1=")
        ]
        return any(hmac.compare_digest(expected, candidate) for candidate in candidates)


    # Example: FastAPI handler
    @app.post("/webhooks/mesh")
    async def handle_mesh_webhook(request: Request):
        raw_body = await request.body()  # raw bytes, not request.json()
        timestamp = request.headers["Mesh-Webhook-Timestamp"]
        signature = request.headers["Mesh-Webhook-Signature"]

        if not verify_signature(WEBHOOK_SECRET, timestamp, raw_body, signature):
            raise HTTPException(status_code=400, detail="Invalid signature")

        event = json.loads(raw_body)
        # handle event["type"], dedupe on event["id"]
        return {"received": True}
    ```
  </Tab>

  <Tab title="Node.js">
    ```typescript theme={null}
    import crypto from "crypto";

    const TOLERANCE_SECONDS = 300; // 5 minutes

    function verifySignature(
      secret: string,
      timestamp: string,
      rawBody: Buffer,
      signatureHeader: string,
    ): boolean {
      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
        return false;
      }

      const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
      const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
      const expectedBuf = Buffer.from(expected, "hex");

      // One v1=<hex> term per active secret — two during a rotation grace
      // window. Accept if ANY term matches.
      const candidates = signatureHeader
        .split(",")
        .filter((term) => term.startsWith("v1="))
        .map((term) => term.slice(3));

      return candidates.some((candidate) => {
        const candidateBuf = Buffer.from(candidate, "hex");
        return (
          candidateBuf.length === expectedBuf.length &&
          crypto.timingSafeEqual(expectedBuf, candidateBuf)
        );
      });
    }

    // Example: Express handler — mount with express.raw() so req.body is a Buffer,
    // not a parsed object.
    app.post(
      "/webhooks/mesh",
      express.raw({ type: "application/json" }),
      (req, res) => {
        const timestamp = req.header("Mesh-Webhook-Timestamp")!;
        const signature = req.header("Mesh-Webhook-Signature")!;

        if (!verifySignature(WEBHOOK_SECRET, timestamp, req.body, signature)) {
          return res.status(400).json({ error: "Invalid signature" });
        }

        const event = JSON.parse(req.body.toString("utf8"));
        // handle event.type, dedupe on event.id
        res.json({ received: true });
      },
    );
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    package main

    import (
    	"crypto/hmac"
    	"crypto/sha256"
    	"encoding/hex"
    	"io"
    	"math"
    	"net/http"
    	"strconv"
    	"strings"
    	"time"
    )

    const toleranceSeconds = 300 // 5 minutes

    func verifySignature(secret, timestamp string, rawBody []byte, signatureHeader string) bool {
    	ts, err := strconv.ParseInt(timestamp, 10, 64)
    	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
    		return false
    	}

    	mac := hmac.New(sha256.New, []byte(secret))
    	mac.Write([]byte(timestamp + "."))
    	mac.Write(rawBody)
    	expected := hex.EncodeToString(mac.Sum(nil))

    	// One v1=<hex> term per active secret — two during a rotation grace
    	// window. Accept if ANY term matches.
    	for _, term := range strings.Split(signatureHeader, ",") {
    		term = strings.TrimSpace(term)
    		if !strings.HasPrefix(term, "v1=") {
    			continue
    		}
    		candidate := strings.TrimPrefix(term, "v1=")
    		if hmac.Equal([]byte(expected), []byte(candidate)) {
    			return true
    		}
    	}
    	return false
    }

    func handleMeshWebhook(w http.ResponseWriter, r *http.Request) {
    	rawBody, _ := io.ReadAll(r.Body) // raw bytes, not json.Decode straight from r.Body
    	timestamp := r.Header.Get("Mesh-Webhook-Timestamp")
    	signature := r.Header.Get("Mesh-Webhook-Signature")

    	if !verifySignature(webhookSecret, timestamp, rawBody, signature) {
    		http.Error(w, "invalid signature", http.StatusBadRequest)
    		return
    	}

    	// unmarshal rawBody, handle event.Type, dedupe on event.ID
    	w.WriteHeader(http.StatusOK)
    }
    ```
  </Tab>
</Tabs>

## Delivery semantics

* **At-least-once, never exactly-once.** The same event can arrive more than once — a retried attempt after a slow-but-successful response, or a manual redelivery. `Mesh-Event-Id` is your idempotency key: dedupe on it before acting on an event a second time.
* **No ordering guarantee.** Deliveries for different events can arrive out of order. Order on the payload's `created_at`, not on arrival time.
* **Success is 2xx only** (webhook endpoints). Any other response — including a redirect — counts as a failed attempt. Redirects are not followed.
* **Retries with backoff.** The first attempt fires immediately. On failure, up to 5 more attempts follow — roughly `30s, 2m, 10m, 1h, 6h` after the previous one (jittered ±20%) — for 6 attempts total. After the last one fails, the delivery is marked `dead`: it stays visible in your delivery log and can be redelivered manually, but is not retried automatically again.
* **Timeout.** Each attempt waits up to 10 seconds for your endpoint to respond.
* **For email recipients**, at-least-once, ordering and the retry schedule are the same. A `succeeded` email delivery means the message was **accepted for sending** — it is not a receipt. A message accepted and then bounced by the receiving server is not currently reflected in the log, so treat the log as "we sent it", not "they got it".

## Inspecting deliveries

| Endpoint                                      | Behavior                                                                                                                                |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/webhooks/deliveries`                 | List this org's deliveries. Filter by `channel_id`, `event_type`, `status`, `since`, `until`; cursor-paginated via `cursor` and `limit` |
| `GET /v1/webhooks/deliveries/{id}`            | Full detail for one delivery, including the exact payload that was (or will be) sent                                                    |
| `POST /v1/webhooks/deliveries/{id}/redeliver` | Queue a fresh attempt for the same event. Org admin only. Returns `409` if the endpoint is disabled or deleted                          |
| `GET /v1/webhooks/deliveries/stats`           | Totals for a window (`window_hours`, default 24) plus a 7-day success rate per endpoint                                                 |
| `POST /v1/webhooks/deliveries/test`           | Send a sample payload for one `event_type` to one `channel_id`. Org admin only                                                          |

```bash theme={null}
curl "https://api.meshapi.ai/v1/webhooks/deliveries?status=dead&event_type=api_key.revoked" \
  -H "Authorization: Bearer YOUR_SESSION_TOKEN"
```

**Response:**

```json theme={null}
{
  "data": [
    {
      "id": "b6e1a9c0-...",
      "event_id": "018f2e3a-...",
      "event_type": "api_key.revoked",
      "channel_id": "9c1b1e3a-...",
      "status": "dead",
      "attempt_count": 6,
      "response_status": 503,
      "last_error": "connect timeout",
      "next_attempt_at": null,
      "last_attempt_at": "2026-08-03T16:40:02Z",
      "created_at": "2026-08-03T10:04:11Z"
    }
  ],
  "next_cursor": null
}
```

| Field             | Meaning                                                                                                                                           |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`          | `pending` (queued or awaiting retry), `delivering` (attempt in flight), `succeeded`, or `dead` (retries exhausted — redeliver manually if needed) |
| `attempt_count`   | Number of delivery attempts made so far                                                                                                           |
| `response_status` | HTTP status your endpoint returned on the last attempt, if any                                                                                    |
| `last_error`      | Error from the last attempt — a non-2xx body, or a transport error like a timeout                                                                 |
| `next_attempt_at` | When the next automatic retry is scheduled; `null` once the delivery is `succeeded` or `dead`                                                     |

Redelivering creates a **new** delivery row with the same `event_id` and payload — the original attempt's history is never modified, so both remain in your log.

### Sending a test event

```bash theme={null}
curl -X POST https://api.meshapi.ai/v1/webhooks/deliveries/test \
  -H "Authorization: Bearer YOUR_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "api_key.created",
    "channel_id": "9c1b1e3a-..."
  }'
```

The test is a **real delivery**: it is signed, retried, dead-lettered and logged
exactly like a live event, because a test that took a different code path would
not tell you whether your real events will arrive. Your receiver can tell it
apart by `test` on the payload:

```json theme={null}
{
  "id": "evt_01j...",
  "type": "api_key.created",
  "test": true,
  "data": { "key_id": "3a7c9e21-...", "label": "Production", "masked_key": "rsk_...a1b2" }
}
```

Org admins only. A paused endpoint answers `409`, and an email recipient
answers `422` — test events go to webhook endpoints.

## Rotating your signing secret

```bash theme={null}
curl -X POST https://api.meshapi.ai/alerts/channels/9c1b1e3a-.../rotate-secret \
  -H "Authorization: Bearer YOUR_SESSION_TOKEN"
```

**Response:**

```json theme={null}
{
  "signing_secret": "whsec_ab12...ef90",
  "grace_until": "2026-08-04T10:00:00Z"
}
```

Your old secret keeps verifying until `grace_until` (24 hours after rotation) — deliveries in that window are signed with **both** secrets (see the dual `v1=` terms above), so you can update your stored secret and redeploy without dropping any deliveries in between. After the grace window, only the new secret verifies.
