# Authentication and request signing

Source: https://caravelpay.app/docs/authentication.html · Caravel Pay docs · Last updated 2026-11-09

Every API request carries your merchant id, a timestamp and an HMAC-SHA256 signature made with your secret. Webhooks we send you are signed the same way. The one unsigned call is [`POST /v1/connect`](https://caravelpay.app/docs/connect.html), which gives you the keys.

## Credentials

| Credential | Looks like | Where it lives |
|---|---|---|
| **Merchant id** | `mch_7Q2KZX4PRB` | Sent in a header with every request. Not secret. |
| **Secret** | 64 hex characters | Your server only. Never in browser, app or plugin code that buyers can see. |

You get both from one call to `POST /v1/connect`: see [Get your API keys](https://caravelpay.app/docs/connect.html). The WooCommerce plugin makes that call and stores them for you. Use the secret as a plain string: don't hex-decode it.

If your secret leaks or gets lost, [get new keys](https://caravelpay.app/docs/connect.html#reconnect) with the same call and switch to them. Email [hello@caravelpay.app](mailto:hello@caravelpay.app) with the old merchant id if it should be paused.

## Headers

| Header | Value |
|---|---|
| `Caravel-Merchant` | Your merchant id. |
| `Caravel-Timestamp` | Current time in unix seconds, for example `1794233052`. Must be within 300 seconds of our clock. |
| `Caravel-Signature` | Lowercase hex HMAC-SHA256 of the string to sign, keyed with your secret. |
| `Content-Type` | `application/json` on requests with a body. |

## Build the signature

Join four values with a single newline (`\n`) between each, with no newline at the end:

*String to sign*

```text
${timestamp}\n${METHOD}\n${path}\n${rawBody}
```

- **timestamp**: the exact value you send in `Caravel-Timestamp`.
- **METHOD**: uppercase, `POST` or `GET`.
- **path**: the path without scheme or host, such as `/v1/checkouts` or `/v1/checkouts/chk_8Qe2TnV4kR`. Include the query string if you send one, exactly as sent: `/v1/checkouts?limit=20`.
- **rawBody**: the exact bytes you send as the body. Use an empty string for `GET`.

Then compute HMAC-SHA256 of that string with your secret as the key, and send it as lowercase hex.

*Sign a request · curl*

```sh
TS=$(date +%s)
BODY='{"order_id":"1042","amount":"136.41","currency":"USD"}'
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" POST /v1/checkouts "$BODY" \
  | openssl dgst -sha256 -hmac "$CARAVEL_SECRET" | awk '{print $NF}')
echo "$SIG"
```

*Sign a request · Node.js*

```js
import crypto from "node:crypto";

function sign(secret, timestamp, method, path, rawBody = "") {
  return crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}\n${method}\n${path}\n${rawBody}`)
    .digest("hex");
}
```

*Sign a request · PHP*

```php
<?php
function caravel_sign(string $secret, string $timestamp, string $method, string $path, string $rawBody = ''): string
{
    return hash_hmac('sha256', "$timestamp\n$method\n$path\n$rawBody", $secret);
}
```

*Sign a request · Python*

```py
import hashlib, hmac

def sign(secret: str, timestamp: str, method: str, path: str, raw_body: bytes = b"") -> str:
    message = f"{timestamp}\n{method}\n{path}\n".encode() + raw_body
    return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
```

The [Quickstart](https://caravelpay.app/docs/#create) has a complete request helper in each language that signs and sends in one call.

## Check your implementation

Sign these example values. If your code prints the signature below, it matches ours. (This secret is only an example; it doesn't work against the API.)

- **Secret**: `9b2e7c4a1f6d8e3b5a0c9d2f7e4b1a6c3d8f5e2b9a7c4d1e6f3b8a5c2d9e7f4a`
- **Timestamp**: `1794233052`
- **Method**: `POST`
- **Path**: `/v1/checkouts`
- **Body**: `{"order_id":"1042","amount":"136.41","currency":"USD","return_url":"https://shop.example/thanks","cancel_url":"https://shop.example/cart","callback_url":"https://shop.example/webhooks/caravel"}`
- **Signature**: `34fd1584ba0f542dd68864a0a4c77df7c2a8532a4b254b4e865ad98e78ee2c4c`

## Timestamps and clock skew

Requests whose `Caravel-Timestamp` is more than 300 seconds away from our clock are rejected with `timestamp_expired`. This stops an old, captured request from being replayed. Keep your server clock synced with NTP and create the timestamp right before you send.

## Common mistakes

- **Signing one body and sending another.** Serialise the JSON once, sign that string, and send the same string. Don't let your HTTP client re-encode it.
- **Signing the full URL.** Sign `/v1/checkouts`, not `https://api.caravelpay.app/v1/checkouts`.
- **Lowercase method or uppercase hex.** Method is uppercase; the signature is lowercase hex.
- **A trailing newline.** `echo` adds one; `printf '%s'` doesn't.

A request that fails any of these checks gets `401` with the code `invalid_signature`. See [Errors](https://caravelpay.app/docs/errors.html).
