# Authentication

> Sign every request with HMAC-SHA256 and encrypt the payload with AES-256-CBC.

> For the complete documentation index, see [llms.txt](https://helpdesk.orangescrum.com/llms.txt).

Source: https://helpdesk.orangescrum.com/guide/api/authentication

---
The Developer API uses an **API key plus an HMAC signature**. The key identifies
you; the signature proves you hold the secret and that nobody altered the request
in transit. The secret itself is never sent.

> **Store the secret carefully**
>
> The secret key is shown **once**, when the key pair is created. It cannot be
> retrieved afterwards — only rotated. Keep it in a secrets manager, never in
> source control or client-side code.

## Credentials

| Value | Where it goes | Notes |
| --- | --- | --- |
| **API key** | `X-API-KEY` header | Public identifier, prefixed `pk_` |
| **Secret key** | never transmitted | Used only to compute the signature and encrypt the payload |

## Required headers

Every request carries six headers:

- `Accept` *string* (required) — `application/json`

- `Content-Type` *string* (required) — `application/json`

- `X-API-KEY` *string* (required) — Your API key, starting with `pk_`.

- `X-TIMESTAMP` *integer* (required) — Unix timestamp in seconds. Must be within **±5 minutes** of server time, so keep the calling machine's clock in sync via NTP.

- `X-NONCE` *string* (required) — A one-time random string, **minimum 8 characters**. Each nonce is remembered for 10 minutes and cannot be reused within that window.

- `X-SIGNATURE` *string* (required) — Hex-encoded HMAC-SHA256 of the canonical string, keyed with your secret.

## The canonical string

The signature is computed over a deterministic representation of the request —
five fields joined by newlines, in this exact order:

```
METHOD\nPATH\nTIMESTAMP\nNONCE\nSHA256(BODY)
```

| Field | Value |
| --- | --- |
| `METHOD` | Uppercase HTTP method, always `POST` |
| `PATH` | Request path including the leading slash, e.g. `/api/v1/partner/tasks/list` |
| `TIMESTAMP` | The same value sent in `X-TIMESTAMP` |
| `NONCE` | The same value sent in `X-NONCE` |
| `SHA256(BODY)` | Lowercase hex SHA-256 of the **raw request body**, byte for byte |

> **Hash the bytes you actually send**
>
> Serialise the body once, hash that exact string, and send that exact string.
> Re-encoding the JSON between hashing and sending — different key order, added
> whitespace — changes the hash and the signature will not match.

## Encrypting the payload

Request parameters are not sent as plain JSON. They are encrypted and wrapped in
a single field:

```json
{
  "encrypted_data": "<base64 string>"
}
```

The encryption is **AES-256-CBC**:

- **Key** — the SHA-256 hash of your secret key (32 raw bytes)
- **IV** — 16 random bytes, generated per request
- **Output** — the IV prepended to the ciphertext, then Base64-encoded

Responses come back as ordinary JSON — you do not need to decrypt them.

## Worked example

```php
<?php

$apiKey    = 'pk_your_api_key';
$secretKey = 'your_secret_key';
$path      = '/api/v1/partner/tasks/list';
$payload   = ['project_id' => '550e8400-e29b-41d4-a716-446655440000'];

// 1. Encrypt the payload with AES-256-CBC.
$iv        = random_bytes(16);
$cipher    = openssl_encrypt(
    json_encode($payload),
    'aes-256-cbc',
    hash('sha256', $secretKey, true),
    OPENSSL_RAW_DATA,
    $iv
);
$body = json_encode(['encrypted_data' => base64_encode($iv . $cipher)]);

// 2. Sign the canonical string.
$timestamp = time();
$nonce     = bin2hex(random_bytes(8));
$canonical = implode("\n", ['POST', $path, $timestamp, $nonce, hash('sha256', $body)]);
$signature = hash_hmac('sha256', $canonical, $secretKey);

// 3. Send it.
$ch = curl_init("https://v4-api.orangescrum.com{$path}");
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Accept: application/json',
        'Content-Type: application/json',
        "X-API-KEY: {$apiKey}",
        "X-TIMESTAMP: {$timestamp}",
        "X-NONCE: {$nonce}",
        "X-SIGNATURE: {$signature}",
    ],
]);

echo curl_exec($ch);
```

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

const API_KEY = 'pk_your_api_key';
const SECRET = 'your_secret_key';
const HOST = 'https://v4-api.orangescrum.com';

function encrypt(payload, secret) {
  const iv = crypto.randomBytes(16);
  const key = crypto.createHash('sha256').update(secret).digest();
  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
  const encrypted = Buffer.concat([
    cipher.update(JSON.stringify(payload), 'utf8'),
    cipher.final(),
  ]);
  return Buffer.concat([iv, encrypted]).toString('base64');
}

export async function call(path, payload = {}) {
  // Serialise once — the hash must cover the exact bytes we send.
  const body = JSON.stringify({ encrypted_data: encrypt(payload, SECRET) });

  const timestamp = Math.floor(Date.now() / 1000);
  const nonce = crypto.randomBytes(8).toString('hex');
  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');

  const canonical = ['POST', path, timestamp, nonce, bodyHash].join('\n');
  const signature = crypto
    .createHmac('sha256', SECRET)
    .update(canonical)
    .digest('hex');

  const response = await fetch(`${HOST}${path}`, {
    method: 'POST',
    body,
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      'X-API-KEY': API_KEY,
      'X-TIMESTAMP': String(timestamp),
      'X-NONCE': nonce,
      'X-SIGNATURE': signature,
    },
  });

  return response.json();
}

console.log(await call('/api/v1/partner/projects/list', { page: 1, limit: 25 }));
```

```python
import base64, hashlib, hmac, json, os, time

import requests
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

API_KEY = "pk_your_api_key"
SECRET = "your_secret_key"
HOST = "https://v4-api.orangescrum.com"

def encrypt(payload: dict, secret: str) -> str:
    iv = os.urandom(16)
    key = hashlib.sha256(secret.encode()).digest()

    padder = padding.PKCS7(128).padder()
    data = padder.update(json.dumps(payload).encode()) + padder.finalize()

    encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
    return base64.b64encode(iv + encryptor.update(data) + encryptor.finalize()).decode()

def call(path: str, payload: dict | None = None) -> dict:
    # Serialise once — the hash must cover the exact bytes we send.
    body = json.dumps({"encrypted_data": encrypt(payload or {}, SECRET)})

    timestamp = int(time.time())
    nonce = os.urandom(8).hex()
    body_hash = hashlib.sha256(body.encode()).hexdigest()

    canonical = "\n".join(["POST", path, str(timestamp), nonce, body_hash])
    signature = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()

    response = requests.post(
        f"{HOST}{path}",
        data=body,
        headers={
            "Accept": "application/json",
            "Content-Type": "application/json",
            "X-API-KEY": API_KEY,
            "X-TIMESTAMP": str(timestamp),
            "X-NONCE": nonce,
            "X-SIGNATURE": signature,
        },
        timeout=30,
    )
    return response.json()

print(call("/api/v1/partner/projects/list", {"page": 1, "limit": 25}))
```

## Scopes

Each key carries a list of scopes. A key with `*` can reach everything; a
narrower key is rejected with `403 Forbidden` when it calls outside its scope.
Ask for the narrowest set that does the job.

## Why a request fails

**401 — Invalid signature**

    Almost always the body hash. Confirm you hashed the exact bytes you sent,
    that the canonical string uses `\n` (not `\r\n`), and that the field order is
    `METHOD`, `PATH`, `TIMESTAMP`, `NONCE`, `SHA256(BODY)`.

**401 — Timestamp outside window**

    The clock on the calling machine has drifted more than five minutes. Sync it
    with NTP; do not paper over it by reading the server clock first.

**401 — Nonce already used**

    The same nonce was replayed inside the 10-minute window. Generate a fresh
    random value per request — never a counter that resets.

**401 — Unknown or revoked key**

    The key does not exist, is inactive, has expired, or has been revoked. Check
    it in the admin portal.

Every attempt — successful or not — is written to the audit trail with the
calling IP, the endpoint and the reason for failure.

- [Validate your setup](https://helpdesk.orangescrum.com/guide/api/endpoints/developer/validate): Call `/api/v1/partner/validate` first. It exercises the whole signing path without touching any data.
