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

# Pulse Security

> Verify that a Pulse genuinely came from Chariow, and deduplicate retries safely

Every Pulse Chariow sends is signed with a secret that belongs to that Pulse alone. Verifying that signature is the only way to know a request on your endpoint genuinely originates from Chariow — your endpoint URL is public, so anyone who discovers it can post to it.

This page is the complete signing contract. Implement it once and you can trust every payload you receive.

## Request headers

Each delivery carries these headers:

| Header                | Contents                                           |
| --------------------- | -------------------------------------------------- |
| `x-chariow-signature` | `sha256=<hex digest>` — the signature to verify    |
| `x-pulse-id`          | The Pulse identifier, e.g. `pulse_abc123`          |
| `x-pulse-delivery-id` | The delivery identifier — **your idempotency key** |
| `x-pulse-event`       | The event name, e.g. `successful.sale`             |
| `Content-Type`        | `application/json; charset=utf-8`                  |
| `User-Agent`          | `Pulse \| Chariow +https://chariow.com`            |

<Note>
  Test events sent from the dashboard carry `x-pulse-id` and `x-pulse-event` but **no** `x-pulse-delivery-id`, because no delivery record is created for them. Their payload also contains an extra `note` field. Real events always carry all four headers.
</Note>

## The signing secret

Each Pulse has its own signing secret, prefixed `whsec_`, generated by Chariow when the Pulse is created.

<Warning>
  The signing secret is **not** your API key, and it is not derived from your API key, your store identifier, or any other integration credential. It is an independent value. Using anything else to compute the HMAC will never produce a matching signature.
</Warning>

To retrieve it: **Automations → Pulses → select your Pulse → Overview tab → Signing secret**. It is masked by default; use *Reveal*, *Copy* and *Rotate* on that block.

The secret is encrypted at rest and never appears in API listings or request logs — those expose only a masked form such as `whsec_••••a1b2`. The plaintext value is returned only on an explicit reveal.

### Rotating the secret

Rotation takes effect immediately and cannot be undone: the previous secret stops working the moment you rotate. Update your endpoint configuration in the same window.

Deliveries dispatched before the rotation keep the signature computed with the previous secret, so adopt the new secret before replaying old deliveries.

## Signature scheme

| Property    | Value                                        |
| ----------- | -------------------------------------------- |
| Algorithm   | HMAC-SHA256                                  |
| Header      | `x-chariow-signature`                        |
| Format      | `sha256=<64 lowercase hex characters>`       |
| Signed data | The raw HTTP body bytes, exactly as received |
| Key         | The Pulse signing secret (`whsec_...`)       |
| Timestamp   | None, by design                              |

```
signature = "sha256=" + hex( hmac_sha256( raw_request_body, pulse_secret ) )
```

**Only the raw body is signed.** The HTTP method, the URL, the headers, `x-pulse-id`, `x-pulse-delivery-id` and any timestamp are all excluded from the computation.

### Body encoding

The body is compact UTF-8 JSON:

* no indentation whitespace and no newlines;
* forward slashes are escaped — `https:\/\/example.com`;
* non-ASCII characters are escaped as `\uXXXX`, so the transmitted body is ASCII in practice;
* key order is the order in the transmitted body.

<Warning>
  Never re-serialise the parsed payload. `JSON.stringify(req.body)` or `json.dumps(payload)` will not reproduce these bytes — the escaped forward slashes alone are enough to break the digest. Capture the raw body **before** any JSON parsing.
</Warning>

### Comparing the signature

Strip the `sha256=` prefix and compare against the bare hex digest, or rebuild `"sha256=" + digest` and compare the whole string. Either works as long as you are consistent.

Always compare in constant time (`crypto.timingSafeEqual`, `hash_equals`, `hmac.compare_digest`) and check that both values have the same length first — `timingSafeEqual` throws on buffers of different sizes.

The prefix identifies the algorithm so the scheme can evolve without breaking existing receivers. Treat any value that does not start with `sha256=` as a scheme you do not yet support.

<Warning>
  Reject any request without a valid `x-chariow-signature` header with a `401`.
</Warning>

## Verification examples

<CodeGroup>
  ```javascript Node.js / Express theme={null}
  const crypto = require('crypto');

  app.post('/webhooks/chariow',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const received = req.header('x-chariow-signature') ?? '';
      const expected = 'sha256=' + crypto
        .createHmac('sha256', process.env.CHARIOW_PULSE_SECRET)
        .update(req.body)                 // raw Buffer, never a re-serialised object
        .digest('hex');

      const a = Buffer.from(received);
      const b = Buffer.from(expected);

      if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
        return res.status(401).send('Invalid signature');
      }

      const deliveryId = req.header('x-pulse-delivery-id');

      if (alreadyProcessed(deliveryId)) {
        return res.status(200).send('OK');
      }

      res.status(200).send('OK');
      enqueue(deliveryId, JSON.parse(req.body.toString('utf8')));
    }
  );
  ```

  ```php PHP theme={null}
  <?php

  $raw = file_get_contents('php://input');
  $received = $_SERVER['HTTP_X_CHARIOW_SIGNATURE'] ?? '';
  $expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('CHARIOW_PULSE_SECRET'));

  if (! hash_equals($expected, $received)) {
      http_response_code(401);
      exit;
  }

  $deliveryId = $_SERVER['HTTP_X_PULSE_DELIVERY_ID'] ?? null;

  if (already_processed($deliveryId)) {
      http_response_code(200);
      exit;
  }

  $payload = json_decode($raw, true);
  ```

  ```python Python / Flask theme={null}
  import hmac, hashlib, os

  @app.post('/webhooks/chariow')
  def chariow_webhook():
      raw = request.get_data()  # raw bytes, not request.json
      expected = 'sha256=' + hmac.new(
          os.environ['CHARIOW_PULSE_SECRET'].encode(),
          raw,
          hashlib.sha256,
      ).hexdigest()

      received = request.headers.get('X-Chariow-Signature', '')

      if not hmac.compare_digest(expected, received):
          return '', 401

      delivery_id = request.headers.get('X-Pulse-Delivery-Id')

      if already_processed(delivery_id):
          return '', 200

      enqueue(delivery_id, request.get_json())

      return '', 200
  ```
</CodeGroup>

## Idempotency and replay protection

The signature carries no timestamp, and that is deliberate. It is computed once when the delivery is dispatched and reused unchanged by every retry. A time window would wrongly reject a legitimately delayed retry — the last attempt of a delivery can land nearly three hours after the first.

Replay protection is handled by `x-pulse-delivery-id` instead. That identifier is stable across every attempt of the same delivery, which makes it a ready-made idempotency key:

1. Read `x-pulse-delivery-id`.
2. If you have already processed it, return `200` and stop.
3. Otherwise persist it, return `200`, and process asynchronously.

<Tip>
  Deduplicate on `x-pulse-delivery-id`, not on the entity identifier inside the payload. A single sale can legitimately produce several deliveries — one per subscribed Pulse, plus any manual replay you trigger yourself.
</Tip>

A manually replayed delivery is a new delivery with its own `x-pulse-delivery-id`, so it will pass your idempotency check and be processed again. That is the intended behaviour: replay exists to let you reprocess an event your endpoint missed.

## Troubleshooting a signature mismatch

| Symptom                                                               | Likely cause                                                                                                 |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| No representation of the digest ever matches                          | Wrong secret — an API key or a store identifier instead of the Pulse `whsec_...` secret                      |
| Matches sometimes, fails on payloads with URLs or accented characters | The payload was re-serialised instead of hashed raw; escaped forward slashes or `\uXXXX` sequences were lost |
| Every signature fails since a recent change                           | The secret was rotated; the endpoint is still using the old value                                            |
| Comparison throws instead of returning false                          | `timingSafeEqual` on buffers of different lengths — the `sha256=` prefix was stripped on one side only       |
| Signature verifies, but you process the same event twice              | Missing deduplication on `x-pulse-delivery-id`                                                               |

<Tip>
  Use the **Deliveries** tab on the Pulse detail page to replay a real delivery while you debug. It shows the exact payload sent and the exact response your endpoint returned, which is far more useful than a test event for validating verification end to end.
</Tip>

## Related resources

<CardGroup cols={2}>
  <Card title="Pulses Guide" icon="bolt" href="/en/guides/pulses">
    Events, payloads, delivery history and retries
  </Card>

  <Card title="Best Practices" icon="shield-check" href="/en/guides/best-practices">
    Security and reliability recommendations
  </Card>
</CardGroup>
