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

# Signing requests

> Which requests must be signed, and how: from the SDKs, or by hand from any language.

A signature proves a request came from the holder of the key's **signing secret** (`fss_…`) and was not
changed or replayed on the way. Every key gets a signing secret when it is created or rotated, shown once
beside its token, and its **Security** tab sets which of its requests must carry a signature.

> [card] Proves the sender
>
> A token copied from a log or a proxy is not enough on its own.

> [card] Seals the request
>
> The method, path, query and body are all signed: change one and the signature fails.

> [card] Works once
>
> A signed write is spent on use, and every signature expires after five minutes.

## Which requests must be signed

| Request | Must it be signed? |
| - | - |
| Any request from a key whose signing mode is **Token only** | No |
| A write (`POST`, `PUT`, `PATCH`, `DELETE`) from a key on **Signed writes** | Yes, uploads included |
| Any request from a key on **Every request signed** | Yes, reads too |
| A Conversion Data API write: [record a conversion](https://docs.fetchski-attract.com/api-reference/conversion-events/post-a-conversion-another-system-saw.md) or [adjust one](https://docs.fetchski-attract.com/api-reference/conversion-events/retract-or-restate-a-conversion-event.md) | Always, from every API key, whatever its mode |
| A person signed in to the CMS (bearer token or session) | Never |

An unsigned request that had to be signed gets 401, and the message says so.

The API reference says it on each page too: the Conversion Data API's writes carry a **Signed** note, and
an upload a **Signing** note.

## With the SDKs

Give the SDK the signing secret beside the key. It then signs every request it can, picks a fresh
timestamp for each attempt, and signs an upload by its SHA-256. There is nothing to call.

> [tab] TypeScript
>
> ```ini .env theme={"dark"}
> FETCHSKI_ATTRACT_URL=https://acme.example
> FETCHSKI_ATTRACT_API_KEY=fsk_acme_…
> FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…
> ```
>
> ```ts theme={"dark"}
> import { createSiteClient } from '@fetchski/attract-cms-sdk';
>
> const site = createSiteClient(); // reads the three values above
> ```
>
> With options instead, pass `signingSecret` beside `apiKey`. Add `signing: 'writes'` (or `'all'`, the
> key's mode from its **Security** tab) to have the client refuse a request the key can't send before
> it leaves. See [Configuration](https://docs.fetchski-attract.com/typescript/configuration.md).

> [tab] Laravel
>
> ```ini .env theme={"dark"}
> FETCHSKI_ATTRACT_SITE=acme
> FETCHSKI_ATTRACT_URL=https://acme.example
> FETCHSKI_ATTRACT_API_KEY=fsk_acme_…
> FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…
> ```
>
> Every `Attract::` call is then signed as the key needs. For a further site, set `api_signing_secret`
> in its entry, and optionally `api_signing` (`none`, `writes` or `all`) to have the client refuse what
> the key can't send. See [Configuration](https://docs.fetchski-attract.com/laravel/configuration.md).
>
> > [!TIP]
> >
> > Confirm the key is accepted and the clocks agree (a signature allows five minutes):
> >
> > ```bash Terminal theme={"dark"}
> > php artisan attract:check
> > ```

## By hand

**1. Take the time**

The Unix time in seconds, as a string: `1792000000`. It must be within five minutes of the site's
clock.

**2. Build the string to sign**

Join four parts with dots: the timestamp, the method in capitals, the path from `/api/v1` with the
query exactly as sent, and the exact body bytes (nothing for a request without a body).

```text theme={"dark"}
1792000000.POST./api/v1/testimonial?aposMode=draft.{"title":"A note"}
```

**3. Sign it**

HMAC-SHA256 of that string, keyed with the whole signing secret (`fss_` included), as lower-case hex.

**4. Send three headers**

`Authorization: ApiKey fsk_…`, `X-Fetchski-Timestamp: <timestamp>` and
`X-Fetchski-Signature: v1=<hex>`. Send the body you signed, byte for byte.

```bash cURL theme={"dark"}
SECRET="$FETCHSKI_ATTRACT_SIGNING_SECRET"
TS=$(date +%s)
PATH_Q='/api/v1/testimonial?aposMode=draft'
BODY='{"title":"A note"}'
SIG=$(printf '%s' "$TS.POST.$PATH_Q.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)

curl --request POST "https://acme.example$PATH_Q" \
  --header "Authorization: ApiKey $FETCHSKI_ATTRACT_API_KEY" \
  --header 'Content-Type: application/json' \
  --header "X-Fetchski-Timestamp: $TS" \
  --header "X-Fetchski-Signature: v1=$SIG" \
  --data-binary "$BODY"
```

```js Node theme={"dark"}
import { createHmac } from 'node:crypto';

const secret = process.env.FETCHSKI_ATTRACT_SIGNING_SECRET;
const path = '/api/v1/testimonial?aposMode=draft';
const body = JSON.stringify({ title: 'A note' });
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac('sha256', secret)
  .update(`${timestamp}.POST.${path}.${body}`)
  .digest('hex');

await fetch(`https://acme.example${path}`, {
  method: 'POST',
  headers: {
    authorization: `ApiKey ${process.env.FETCHSKI_ATTRACT_API_KEY}`,
    'content-type': 'application/json',
    'x-fetchski-timestamp': timestamp,
    'x-fetchski-signature': `v1=${signature}`
  },
  body
});
```

```php Laravel theme={"dark"}
use Illuminate\Support\Facades\Http;

// The site's entry in config/fetchski-attract.php (filled from .env).
$site = config('fetchski-attract.sites.' . config('fetchski-attract.default'));
$path = '/api/v1/testimonial?aposMode=draft';
$body = json_encode(['title' => 'A note']);
$timestamp = (string) now()->getTimestamp();
$signature = hash_hmac('sha256', "$timestamp.POST.$path.$body", $site['api_signing_secret']);

Http::withHeaders([
    'Authorization' => 'ApiKey ' . $site['api_key'],
    'X-Fetchski-Timestamp' => $timestamp,
    'X-Fetchski-Signature' => "v1=$signature",
])->withBody($body, 'application/json')->post($site['url'] . $path);
```

```python Python theme={"dark"}
import hashlib, hmac, json, os, time
import requests

secret = os.environ["FETCHSKI_ATTRACT_SIGNING_SECRET"]
path = "/api/v1/testimonial?aposMode=draft"
body = json.dumps({"title": "A note"}, separators=(",", ":"))
timestamp = str(int(time.time()))
signature = hmac.new(
    secret.encode(), f"{timestamp}.POST.{path}.{body}".encode(), hashlib.sha256
).hexdigest()

requests.post(
    f"https://acme.example{path}",
    data=body.encode(),
    headers={
        "Authorization": f"ApiKey {os.environ['FETCHSKI_ATTRACT_API_KEY']}",
        "Content-Type": "application/json",
        "X-Fetchski-Timestamp": timestamp,
        "X-Fetchski-Signature": f"v1={signature}",
    },
)
```

> [!NOTE]
>
> These examples take the time with one-second precision. Two identical writes (same method, path and
> body) in the same second carry the same timestamp and signature, and the second is refused as a replay
> (`This signature was already used`). Wait for the next second before sending the same write again, or
> use an SDK, which signs every attempt afresh.

> [!WARNING]
>
> Sign the bytes you send, not a value you serialize again later: a re-encoded body (other spacing, other key
> order, escaped characters) no longer matches. Build the body once, sign it, send that string.

### Which bodies can be signed

> [card] Signed byte for byte
>
> JSON (`application/json`), a form body (`application/x-www-form-urlencoded`), the blog import's
> Markdown (`text/markdown`), or no body at all.

> [card] Signed by its SHA-256
>
> A multipart upload (`multipart/form-data`): see [Uploads](#uploads) below.

Any other body on a request that must be signed gets 401 "This body can’t be
signed": the site keeps the exact bytes of those bodies only.

## Uploads

> [!NOTE]
>
> **A signed upload signs its SHA-256.** A multipart body isn't signed byte for byte: the request declares the
> hex SHA-256 of the exact body in `X-Fetchski-Content-SHA256` and signs that digest in the body's place. The
> site checks the signature before it reads the upload, then the bytes against the digest, and stores nothing
> that doesn't match. The SDKs do this for you; [Signing requests](https://docs.fetchski-attract.com/signing.md#uploads) shows it by hand.

Build the multipart body once, so the bytes you hash are the bytes you send. Then sign the digest where the
body would go, and send it in `X-Fetchski-Content-SHA256`:

```text theme={"dark"}
1792000000.POST./api/v1/@apostrophecms/attachment/upload.<sha256 of the body, hex>
```

```bash cURL theme={"dark"}
SECRET="$FETCHSKI_ATTRACT_SIGNING_SECRET"
BOUNDARY="fetchski$(openssl rand -hex 8)"
{
  printf -- '--%s\r\nContent-Disposition: form-data; name="file"; filename="photo.jpg"\r\nContent-Type: image/jpeg\r\n\r\n' "$BOUNDARY"
  cat photo.jpg
  printf -- '\r\n--%s--\r\n' "$BOUNDARY"
} > body.bin

DIGEST=$(openssl dgst -sha256 -r body.bin | cut -d' ' -f1)
TS=$(date +%s)
PATH_Q='/api/v1/@apostrophecms/attachment/upload'
SIG=$(printf '%s' "$TS.POST.$PATH_Q.$DIGEST" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)

curl --request POST "https://acme.example$PATH_Q" \
  --header "Authorization: ApiKey $FETCHSKI_ATTRACT_API_KEY" \
  --header "Content-Type: multipart/form-data; boundary=$BOUNDARY" \
  --header "X-Fetchski-Content-SHA256: $DIGEST" \
  --header "X-Fetchski-Timestamp: $TS" \
  --header "X-Fetchski-Signature: v1=$SIG" \
  --data-binary @body.bin
```

```js Node theme={"dark"}
import { createHash, createHmac } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const secret = process.env.FETCHSKI_ATTRACT_SIGNING_SECRET;
const url = 'https://acme.example/api/v1/@apostrophecms/attachment/upload';
const path = new URL(url).pathname;

const form = new FormData();
form.append('file', new Blob([await readFile('photo.jpg')], { type: 'image/jpeg' }), 'photo.jpg');
// Serialize it once: these exact bytes are hashed and sent.
const request = new Request(url, { method: 'POST', body: form });
const contentType = request.headers.get('content-type'); // with its boundary
const body = new Uint8Array(await request.arrayBuffer());

const digest = createHash('sha256').update(body).digest('hex');
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac('sha256', secret)
  .update(`${timestamp}.POST.${path}.${digest}`)
  .digest('hex');

await fetch(url, {
  method: 'POST',
  headers: {
    authorization: `ApiKey ${process.env.FETCHSKI_ATTRACT_API_KEY}`,
    'content-type': contentType,
    'x-fetchski-content-sha256': digest,
    'x-fetchski-timestamp': timestamp,
    'x-fetchski-signature': `v1=${signature}`
  },
  body
});
```

With the SDKs, an upload is one call and signed for you:

```ts TypeScript theme={"dark"}
const attachment = await site.attachments().uploadAttachment({ file });
```

```php Laravel theme={"dark"}
$attachment = Attract::attachments()->uploadAttachment(file: $request->file('photo'));
```

## When a signature is refused

Each refusal is 401, and its message says which check failed:

| The message starts | What to fix |
| - | - |
| `This request must be signed` | Send `X-Fetchski-Timestamp` and `X-Fetchski-Signature`: the key's mode (or the route) needs them. |
| `This API key has no signing secret yet` | The key predates signing: make a new key under **API Keys**, which comes with a secret. |
| `X-Fetchski-Timestamp is missing, malformed or more than 5 minutes` | Sign with the current time, and check the server's clock. |
| `X-Fetchski-Signature does not match this request` | Sign the exact path, query and body you send, with the whole `fss_…` secret. |
| `This signature was already used` | Sign every write afresh, with a new timestamp (the SDKs do). |
| `This body can’t be signed` | Send JSON, a form body or Markdown, or upload as multipart with its SHA-256. |
| `A signed multipart upload must declare its body’s SHA-256` | Send `X-Fetchski-Content-SHA256` (64 lower-case hex) and sign that digest. |
| `The upload does not match its X-Fetchski-Content-SHA256` | Hash the exact bytes you send, after the body is built. |

> [!NOTE]
>
> The playground in the API reference can't sign. Try calls there with a key whose signing mode is
> **Token only** (see [Authentication](https://docs.fetchski-attract.com/authentication.md#trying-calls-in-the-playground)); a Conversion Data
> API write can't be tried there at all.


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