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

# Export Destinations

> Unified export destinations — CRUD, credential lifecycle, and webhook signature verification

# Export Destinations API

`/v1/integrations/destinations` is the unified surface for every place Quint ships your organization's data, across both delivery lanes:

* **Batch lane** — the SIEM exporter periodically delivers matched events to `s3`, `splunk_hec`, and `webhook` destinations.
* **Realtime lane** — the alert processor delivers individual alerts to `alert_webhook` destinations as they fire, signed with a per-destination HMAC key.

All endpoints require an **admin-role JWT** (`Authorization: Bearer <JWT>` — see [Authentication](/api/authentication)).

<Note>
  The legacy `/v1/integrations/siem` endpoints remain fully supported as a byte-compatible alias over the same data, restricted to the three batch types. New integrations should use `/v1/integrations/destinations`.
</Note>

## Endpoints

| Method   | Path                                             | Purpose                                                      |
| -------- | ------------------------------------------------ | ------------------------------------------------------------ |
| `GET`    | `/v1/integrations/destinations`                  | List destinations (paginated: `limit`, `offset`)             |
| `POST`   | `/v1/integrations/destinations`                  | Create a destination                                         |
| `GET`    | `/v1/integrations/destinations/{id}`             | Get one destination                                          |
| `PUT`    | `/v1/integrations/destinations/{id}`             | Update config / format / filters / enabled                   |
| `DELETE` | `/v1/integrations/destinations/{id}`             | Delete a destination (never gated, never rate-limited)       |
| `POST`   | `/v1/integrations/destinations/{id}/rotate`      | Rotate an `alert_webhook` signing key (reveal-once)          |
| `GET`    | `/v1/integrations/destinations/{id}/fingerprint` | Read credential metadata + key fingerprint                   |
| `POST`   | `/v1/integrations/destinations/{id}/test`        | Fire a signed synthetic test delivery (`alert_webhook` only) |

## The destination object

```json theme={null}
{
  "id": "1f0a7c2e-4b6d-4f7e-9c1a-2d3e4f5a6b7c",
  "org_id": "9b8a7f6e-5d4c-3b2a-1f0e-9d8c7b6a5f4e",
  "type": "alert_webhook",
  "enabled": true,
  "config": { "url": "https://alerts.example.com/quint" },
  "credential_ref": {
    "mode": "derived_hmac",
    "key_version": 2,
    "fingerprint_sha256": "ab12cd34…"
  },
  "format": "json",
  "filters": null,
  "last_export_at": null,
  "last_export_event_id": null,
  "last_attempt_at": null,
  "last_error_at": null,
  "last_error": null,
  "consecutive_failures": 0,
  "events_exported_total": 0,
  "last_test_at": "2026-08-23T10:12:00Z",
  "last_test_status": "ok",
  "last_test_latency_ms": 184,
  "delivery_state": "active",
  "delivery_detail": "Delivering. Events exported so far: 1204.",
  "created_at": "2026-08-01T09:00:00Z",
  "updated_at": "2026-08-23T10:12:00Z"
}
```

* **`type`** — one of `s3`, `splunk_hec`, `webhook`, `alert_webhook`. Further types (`datadog`, `sentinel`, `elastic`, `sumo`, `s3_parquet`) will be accepted only when their delivery adapter ships.
* **`format`** — `json` (default), `cef`, `ocsf`, or `parquet`.
* **`config`** — per-type settings (see [per-type setup](#per-type-setup)). Responses return a **redacted projection**: non-secret values in full, secret-bearing values replaced with a marker (key names survive, so you can confirm *which* fields you configured).
* **`delivery_state`** — `disabled`, `unknown`, `pending_delivery`, `failing`, `credential_invalid`, `awaiting_events`, or `active`, with a sentence of explanation in `delivery_detail`.

<Note>
  `credential_invalid` means the destination is rejecting the configured credential (401/403). Deliveries are **paused** rather than retried against a dead key; write a new credential (batch types) or rotate the signing key (`alert_webhook`) to resume. `alert_webhook` rows report `unknown` platform delivery state — the realtime lane has no batch-exporter heartbeat, so batch liveness is deliberately not claimed for them.
</Note>

## `credential_ref` — write-only credential semantics

Every destination row carries a `credential_ref` describing **how** it authenticates, in one of four modes:

| Mode              | Meaning                                                                         | Secret at rest     |
| ----------------- | ------------------------------------------------------------------------------- | ------------------ |
| `none`            | No credential needed (or credential rides in `config` for `webhook` headers)    | —                  |
| `derived_hmac`    | Quint-minted webhook signing key, re-derived on demand from a KMS master key    | **Nothing stored** |
| `federated`       | Cross-account role assumption (`aws_assume_role`); config values are non-secret | **Nothing stored** |
| `stored_envelope` | KMS-enveloped ciphertext in a table the API tier cannot read                    | Ciphertext only    |

Credential values are **write-only**:

* You supply a credential on `POST`/`PUT` (e.g. `config.token` for Splunk HEC). It is never echoed back — not in the create response, not on any subsequent read.
* Reads return **metadata only**: `mode`, key/slot version numbers, `fingerprint_sha256`, `last4`, `expires_at`, `credential_updated_at`, `last_verified_at`, `grace_until` — enough to confirm which credential is configured and whether it is due for rotation, never enough to recover it.
* For `alert_webhook`, the signing secret is revealed **exactly once**, in the `201`/rotate response body. Quint does not store it and cannot show it again. **No reveal endpoint exists**; rotation is the only way to obtain a new secret.
* `credential_ref` cannot be changed through the plain `PUT` update — attempting to send one returns `400`. Credential state moves only through the dedicated lifecycle paths (rotate, credential writes).

### Fingerprint check

`GET /v1/integrations/destinations/{id}/fingerprint` is the support surface: it returns the credential metadata including `fingerprint_sha256` — the SHA-256 of the signing key — so you and Quint support can confirm "the key you hold matches the key we would derive" without either side ever transmitting the key.

```bash theme={null}
curl https://api.quintai.dev/v1/integrations/destinations/DEST_ID/fingerprint \
  -H "Authorization: Bearer YOUR_JWT"
```

To compute your side: base64-decode the portion of your secret after the `whsec_` prefix, SHA-256 it, and compare the hex.

## Rate limits

Three endpoints mint or exercise credentials and are deliberately budgeted per hour; everything else is unlimited:

| Endpoint                         | Budget                   |
| -------------------------------- | ------------------------ |
| `POST /destinations` (create)    | 10 / hour / organization |
| `POST /destinations/{id}/rotate` | 10 / hour / destination  |
| `POST /destinations/{id}/test`   | 30 / hour / organization |

Over-budget requests return `429` with `Retry-After`; responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`. Delete and disable are **never** rate-limited or gated — removing a destination whose credential leaked must always work immediately.

## Per-type setup

### Splunk HEC (`splunk_hec`)

```json theme={null}
{
  "type": "splunk_hec",
  "config": {
    "url": "https://http-inputs-example.splunkcloud.com",
    "token": "YOUR-HEC-TOKEN-GUID",
    "index": "quint",
    "sourcetype": "quint:event"
  },
  "format": "json"
}
```

Recommended Splunk-side setup:

1. **Create a dedicated HEC token for Quint** — never reuse a token shared with other senders, so revoking Quint's access never breaks anything else and Splunk-side audit attributes traffic correctly.
2. **Restrict the token with `allowedIndexes`** to the single index Quint should write (e.g. `quint`), so a leaked token cannot write anywhere else.
3. To rotate: create a new token in Splunk, `PUT` the destination with the new `config.token`, verify delivery, then delete the old token in Splunk. Rotation is customer-driven — there is no Quint-side rotate for vendor-held credentials.

`token` is write-only: reads return it redacted. Delivery uses `Authorization: Splunk <token>` against `/services/collector/event`.

### Batch webhook (`webhook`)

```json theme={null}
{
  "type": "webhook",
  "config": {
    "url": "https://siem.example.com/ingest",
    "headers": { "Authorization": "Bearer YOUR-TOKEN" }
  }
}
```

Quint POSTs a JSON array of events to `config.url`, attaching any `config.headers` you supply verbatim.

<Warning>
  **Batch webhook deliveries are unsigned.** The headers you supply are the *only* origin authentication on this lane — a receiver that does not validate them has no way to distinguish Quint's deliveries from anyone else's POST. Treat header values as bearer secrets (they are write-only and redacted on read). If you need signed deliveries, use the `alert_webhook` lane for alerts; signed batch delivery is a planned `signature_scheme` addition.
</Warning>

The URL must be HTTPS and publicly resolvable; deliveries to private, link-local, or metadata addresses are refused, and redirects are never followed.

### S3 (`s3`)

```json theme={null}
{
  "type": "s3",
  "config": {
    "bucket": "acme-quint-exports",
    "prefix": "quint/",
    "region": "us-east-1"
  }
}
```

Phase 1 `s3` destinations deliver from Quint's exporter using its own AWS identity; grant the exporter write access to your bucket out-of-band with your Quint contact. The cross-account `sts:AssumeRole` flow (`s3_parquet`, `credential_ref.mode: "federated"` with a Quint-generated `ExternalId`) — including the customer trust-policy snippet — ships with Phase 2.

### Alert webhook (`alert_webhook`)

The realtime lane: signed, per-alert HTTP deliveries to your receiver.

```bash theme={null}
curl -X POST https://api.quintai.dev/v1/integrations/destinations \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{"type": "alert_webhook", "config": {"url": "https://alerts.example.com/quint"}}'
```

The `201` response is the destination object **plus, once and never again**:

```json theme={null}
{
  "secret": "whsec_bDNMWm9yZW0raXBzdW0rZG9sb3Irc2l0K2FtZXQ9",
  "secret_notice": "This signing secret is shown ONCE and is not stored by Quint. Save it now; rotation is the only way to obtain a new one."
}
```

Setup flow — **create → configure receiver → test → enable**:

1. Create the destination (it defaults to `enabled: false` — the secret is first revealed in the create response, so no receiver can have verified a delivery yet).
2. Store the `whsec_` secret in your receiver and implement [signature verification](#verifying-webhook-signatures).
3. `POST /destinations/{id}/test` — Quint sends a signed synthetic `quint.test.ping` alert (payload carries `"test": true`) over the exact production wire path and records the outcome on the row.
4. `PUT` the destination with `"enabled": true`. Enabling requires a passed test; pass `"skip_test": true` to enable untested (not recommended).

`config.url` must be HTTPS, without embedded credentials, and must not resolve to a private or metadata address.

#### Rotating the signing key

```bash theme={null}
curl -X POST https://api.quintai.dev/v1/integrations/destinations/DEST_ID/rotate \
  -H "Authorization: Bearer YOUR_JWT"
```

Rotation is never gated (it is how you revoke a leaked secret) and works as follows:

* The response reveals the **new** secret (`whsec_…`) once, with the new `key_version`.
* For a **72-hour grace window** (`grace_until` in the credential metadata), deliveries keep signing `X-Quint-Signature` with the **previous** key — the one your receiver already verifies — while the new key's signature rides in the separate `X-Quint-Signature-Next` header. Update your receiver to the new key any time within the window.
* At `grace_until` the previous key is dropped **automatically** — no confirmation step. Deliveries then sign `X-Quint-Signature` with the new key only.
* Back-to-back rotations supersede any running grace window: exactly one previous key is ever honoured.

## Verifying webhook signatures

This section is the wire contract for `alert_webhook` deliveries.

### The delivery

Each alert is POSTed to `<config.url>/alerts/<alert_type>` — for example `https://alerts.example.com/quint/alerts/divergence` — with a JSON body:

```json theme={null}
{
  "alert_id": "…",
  "org_id": "9b8a7f6e-5d4c-3b2a-1f0e-9d8c7b6a5f4e",
  "severity": "high",
  "type": "divergence",
  "subtype": "credential_read",
  "event_id": "550e8400-e29b-41d4-a716-446655440000",
  "agent_id": "claude-code",
  "session_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
  "tool_name": "cat",
  "risk_score": 87.5,
  "message": "Divergence (credential_read): …",
  "timestamp": "2026-08-23T10:12:00Z"
}
```

### The headers

| Header                   | Value                           | Notes                                                                                                                                                                                            |
| ------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Content-Type`           | `application/json`              |                                                                                                                                                                                                  |
| `X-Quint-Signature`      | `sha256=<hex>`                  | HMAC-SHA256 of the **raw request body**, hex-encoded. Always exactly one value; a second signature is **never** comma-joined into this header.                                                   |
| `X-Quint-Timestamp`      | Unix seconds, e.g. `1787824320` | **Advisory only — see below.**                                                                                                                                                                   |
| `X-Quint-Key-Version`    | e.g. `2`                        | The key version that produced `X-Quint-Signature`. Omitted on destinations still signing with a legacy imported secret (pre-rotation), whose header set is unchanged from the original contract. |
| `X-Quint-Signature-Next` | `sha256=<hex>`                  | Present **only during a rotation grace window**: the same body signed with the *new* key. Absent otherwise.                                                                                      |

### Verification steps

1. Read the raw request body **bytes** — do not re-serialize parsed JSON; any re-encoding changes the bytes and the MAC.
2. Base64-decode your stored secret after the `whsec_` prefix to get the key.
3. Compute `HMAC-SHA256(key, body)` and render as `sha256=` + lowercase hex.
4. Compare against `X-Quint-Signature` with a **constant-time comparison** (`hmac.compare_digest`, `crypto.timingSafeEqual`, Go's `hmac.Equal`).
5. If it does not match and `X-Quint-Signature-Next` is present, compare against that header with your **new** key — a match means a rotation is in progress and you should finish switching to the new secret before the grace window ends.
6. Reject the delivery (non-2xx) on no match; Quint retries failed deliveries up to 3 times with backoff.

```python theme={null}
import base64, hashlib, hmac

def verify(secret: str, body: bytes, signature_header: str) -> bool:
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = "sha256=" + hmac.new(key, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

```javascript theme={null}
const crypto = require("crypto");

function verify(secret, rawBody, signatureHeader) {
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected =
    "sha256=" + crypto.createHmac("sha256", key).update(rawBody).digest("hex");
  return (
    expected.length === signatureHeader.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader))
  );
}
```

<Warning>
  **`X-Quint-Timestamp` is advisory and provides no replay protection.** The signature covers the request body only — the timestamp is *not* included in the MAC, so anyone who captures a delivery can rewrite the timestamp freely and the signature still verifies. You may use it as a freshness *heuristic* (e.g. flag deliveries more than ±5 minutes old for review), but do not build replay protection on it. For replay protection today, deduplicate on payload identity as described below; an authenticated-timestamp scheme (`v2`) is planned — see [signature schemes](#signature-schemes).
</Warning>

### Duplicate deliveries and idempotency

Alert delivery is **at-least-once**: the upstream queue can redeliver a message, and failed HTTP attempts are retried. Quint's own delivery log deduplicates internally on a publish-stable claim key, but that key is not exposed on the wire, so receivers should make processing idempotent on the payload:

* **Do not key idempotency on `alert_id` alone** — it is minted per evaluation, so a queue redelivery of the same underlying event can arrive with a *different* `alert_id`.
* Key on **(`event_id`, `type`, `subtype`)** instead: `event_id` is stable for the underlying event, and one event can legitimately trigger alerts of more than one type.

## Signature schemes

Destinations carry a **reserved** `signature_scheme` config field describing the signing contract above:

* **`v1`** (the only value today, and the default when absent) — the scheme documented on this page: body-only HMAC-SHA256, `X-Quint-Signature`/`X-Quint-Timestamp`/`X-Quint-Key-Version`, unauthenticated timestamp.
* **`v2`** (planned) — an authenticated-timestamp scheme in the Standard-Webhooks style: the MAC is computed over the timestamp *and* the body under the same key-version epoch, closing the replay gap described above. Once shipped, `v2` will be the **recommended default for all new destinations**; `v1` remains supported for existing receivers.

A destination uses exactly **one** scheme; a single row is never dual-format. Because changing the MAC input is receiver-breaking, existing destinations will only move to `v2` deliberately, via rotation-style opt-in — never silently.

## Errors

| Status | Meaning                                                                                                                                                                                                              |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Validation failure (unknown type, missing config, incompatible `credential_ref`, un-deliverable URL, enable-before-test). Validation always wins over gating: a request with two problems gets the `400` first.      |
| `404`  | Destination not found (or belongs to another organization — indistinguishable by design).                                                                                                                            |
| `409`  | Lifecycle conflict — e.g. test-firing a destination still on an imported legacy secret (rotate first).                                                                                                               |
| `429`  | Over a create/rotate/test budget; honor `Retry-After`.                                                                                                                                                               |
| `503`  | Batch delivery is not currently live, so enabling a new batch delivery promise is refused (`Retry-After: 60`); or webhook signing is not configured on this deployment. Disable, delete, and rotate are never gated. |
