---
title: "API keys and webhooks"
description: "Create API keys that let another tool call the Outpost API as a role, and webhooks that send signed Outpost events to another tool."
url: "https://support.outpostplatform.com/depot/api-keys-and-webhooks/"
product: "depot"
type: "how-to"
status: "draft"
ai_summary: "API keys act as one role (never owner), with optional expiry and IP allowlist, shown once, need a second factor to create. Webhooks post chosen events over https to port 443, signed X-Outpost-Signature t=..,v1=.. (HMAC-SHA256 of t.body), 5-minute tolerance, X-Outpost-Event-Id for dedupe, retries up to about 45 hours, redeliver."
availability:
  plans: ["home-lab", "essentials", "enterprise", "msp"]
  permission: "integrations:admin"
  preview: false
source: "https://gitlab.com/outpostplatform/docs/-/edit/main/src/content/docs/depot/api-keys-and-webhooks.md"
license: "CC BY 4.0"
---

# API keys and webhooks

API keys and webhooks connect Outpost to the other tools your organization
runs, such as an accounting system, a warehouse system, or your own scripts.
An API key lets a tool call the [Outpost API](/api/) with the permissions of
one role. A webhook sends Outpost events, such as a received purchase order or
a deployed asset, to a tool as they happen. This page covers creating and
managing both, verifying webhook signatures, and the events you can
subscribe to.

## Before you begin

- Managing API keys and webhooks needs `integrations:admin`, which among the
  built-in roles only the owner holds.
- Creating an API key needs a second factor on your account: a passkey or an
  authenticator app. See
  [Secure your console account](/depot/account-security/#turn-on-two-factor-authentication).
- A webhook receiver must be reachable from the internet over HTTPS on port
  443, with a valid certificate.

## Create an API key

1. Select **Settings** > **Integrations** > **API keys**, then **Create key**.
2. Enter a **Name** that says what uses the key, such as Accounting sync.
3. Choose the **Role** the key acts as. The key can do exactly what this role
   can. Any role except **Owner** can be chosen.
4. Optionally, choose when it **Expires**. The key stops working at the end of
   that day. Leave it empty for no expiry.
5. Optionally, enter **Allowed IP addresses**, one address or CIDR range per
   line, such as `203.0.113.0/24`, up to 50. Leave it empty to allow any
   address.
6. Select **Create key**, and confirm it is you with a second factor.
7. Copy the key from **Your new API key**. It is shown once.

Outpost keeps only a fingerprint of the key, so a lost key cannot be shown
again. Revoke it and create another.

:::danger
Anyone who holds the key can do everything its role allows. Store it in a
secrets manager, give each tool its own key, and choose the narrowest role
that works.
:::

## Use an API key

Send the key in the `Authorization` header of each request:

```text
Authorization: Bearer <api-key>
```

Replace `<api-key>` with the key, which starts `opk_`. A key works on the
console address it was created on.

| Response | Cause |
| --- | --- |
| `401` | The key is unknown, revoked, or expired. |
| `403` | The key's role lacks the permission, the request came from outside **Allowed IP addresses**, or the action needs a second factor, which a key can never give. |
| `429` | More than 600 requests in a minute from the key. Wait for the time in `Retry-After`. |

A key runs as a service account that holds the key's role. The account
never signs in, does not appear in **Settings** > **Team**, and does not
count toward console users. The audit log records the key's actions as
**Integration key:** followed by its name.

Creating API keys and turning on live ordering for a distributor need a
second factor, so a key can never do either.

### Roles for keys

Any role except **Owner** can be given to a key. One role is made for them:

| Role | Holds |
| --- | --- |
| **Integration: sales** | Reading and writing the sales pipeline, and reading purchasing, stock, and assets. For quoting and sales tools. |

To give a key exactly the permissions a tool needs, create a role for it. See
[Manage roles](/depot/manage-roles/).

## Manage API keys

**Settings** > **Integrations** > **API keys** lists each key's **Name**,
**Role**, **Expires**, **Last used**, **Created by**, and **Status**:
**Active**, **Revoked**, or **Expired**.

- **Edit** changes the name, expiry, and allowed addresses. A key's role
  cannot change. Create a new key instead.
- **Revoke** stops the key at once. Anything still using it is refused from
  the next request.

## Add a webhook

1. Select **Settings** > **Integrations** > **Webhooks**, then **Add
   webhook**.
2. Enter a **Name**, and the **URL** Outpost posts to. It must start
   `https://`, and use the standard port.
3. Under **Events**, select the events to send, or **Select all**.
4. Leave **Enabled** selected, and select **Add webhook**.
5. Copy the **Signing secret**. It is shown once.
6. Open the webhook and select **Send test**. A `ping` event is delivered, and
   appears under **Deliveries**.

## What a delivery looks like

Outpost sends each event as a `POST` with a JSON body:

```json
{
  "id": "3f1c2b9a-6d4e-4f0a-9c1b-2a7e5d8c4b10",
  "event": "purchase_order.received",
  "occurredAt": "2026-11-05T14:02:11Z",
  "data": {
    "purchaseOrderId": "8b0e7c1d-2f3a-4b5c-9d6e-7f8a9b0c1d2e",
    "poNumber": "PO-1001",
    "status": "received",
    "fullyReceived": true
  }
}
```

Money is in whole cents, and quantities are decimal strings, such as
`"2.0000"`. Payloads carry IDs, numbers, states, and business names. They never
carry a person's name, email address, or postal address.

Each delivery has these headers:

| Header | Value |
| --- | --- |
| `X-Outpost-Signature` | `t=<unix-seconds>,v1=<signature>` |
| `X-Outpost-Event` | The event name, such as `purchase_order.received`. |
| `X-Outpost-Event-Id` | The event's ID. It is the same on every retry and redelivery of one event, so use it to ignore repeats. |
| `X-Outpost-Delivery` | The ID of this delivery. |

## Verify the signature

Check every delivery before you act on it:

1. Read `t` and every `v1` value from `X-Outpost-Signature`. During a secret
   rotation there may be two `v1` values.
2. Join the `t` value, a full stop, and the raw request body, byte for byte:
   `<t>.<body>`.
3. Compute the HMAC-SHA256 of that string with the webhook's whole signing
   secret, `whsec_` prefix included, as lowercase hex.
4. Accept the delivery when it equals any `v1` value, compared in constant
   time.
5. Refuse it when `t` is more than 5 minutes from your clock.

In Python:

```python
import hashlib
import hmac
import time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    """Check an X-Outpost-Signature header against the raw request body."""
    fields = [part.strip().partition("=") for part in header.split(",")]
    timestamps = [value for key, _, value in fields if key == "t"]
    signatures = [value for key, _, value in fields if key == "v1"]
    if len(timestamps) != 1 or not signatures or not timestamps[0].isdigit():
        return False
    t = timestamps[0]
    if abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)
```

Pass the body exactly as it arrived. Parsing and re-serializing the JSON
first changes the bytes, and the signature no longer matches.

## Delivery and retries

| Rule | Value |
| --- | --- |
| Success | Any `2xx` answer within 10 seconds. |
| Redirects | Not followed. A `3xx` answer is a failure. |
| Addresses | Public addresses only. Private, loopback, and link-local addresses are refused. |
| Retries | After 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, and 24 hours: 8 attempts over about 45 hours. |
| After the last retry | The delivery shows **Gave up**. |
| Kept for | 30 days. |

While deliveries are failing, the webhook shows **Failing since** and the time
of the first failure. A successful delivery clears it.

## Manage webhooks

Open a webhook to see its **Deliveries**, with the **Event**, **State**
(**Waiting**, **Sending**, **Delivered**, or **Gave up**), **Attempts**,
**Last status**, **Last error**, **Next attempt**, and **Delivered** time.
Filter by state or event.

- **Redeliver** sends a delivery again, from the start of the retry schedule,
  with the same body and event ID.
- **Rotate secret** makes a new signing secret, shown once. Update your
  receiver at the same time.
- **Edit** changes the name, URL, events, and **Enabled**. **Delete** removes
  the webhook.

## Events

| Event | Sent when | `data` |
| --- | --- | --- |
| `purchase_order.created` | A purchase order is created. | `purchaseOrderId`, `poNumber`, `supplierId`, `supplierName`, `status`, `totalCents` |
| `purchase_order.approved` | A purchase order is approved. | As above |
| `purchase_order.submitted` | A purchase order is submitted to a distributor. | As above |
| `purchase_order.accepted` | The distributor accepts a submitted order. | As above, plus `distributorOrderId` |
| `purchase_order.received` | Goods are received against it. | `purchaseOrderId`, `poNumber`, `status`, `fullyReceived` |
| `purchase_order.cancelled` | A purchase order is canceled. | As for `created` |
| `receipt.posted`, `receipt.reversed` | A receipt is posted or reversed. | `receiptId`, `purchaseOrderId`, `poNumber`, `lines` with `stockItemId`, `sku`, `qty`, `serialCount` |
| `stock.low` | Stock falls to its reorder point. | `stockItemId`, `sku`, `locationId`, `locationName`, `onHand`, `reorderPoint` |
| `stock.adjusted` | Stock is adjusted. | `stockItemId`, `sku`, `locationId`, `locationName`, `qtyDelta`, `reason`, `onHand` |
| `asset.created`, `asset.state_changed` | An asset is added, or changes state. | `assetId`, `clientId`, `serialNumber`, `manufacturer`, `model`, `state`, and `previousState` on a change |
| `asset.assigned` | An asset is linked to an endpoint. | `assetId`, `clientId`, `serialNumber`, `endpointAgentId` |
| `asset.checked_out`, `asset.checked_in` | Equipment is checked out or in. | `assetId`, `checkoutId`, `serialNumber`, `state`. The person is never named. |
| `asset.warranty_expiring` | An asset's warranty is ending. Sent once per asset and end date. | `assetId`, `clientId`, `serialNumber`, `manufacturer`, `warrantyEndsOn` |
| `supplier_bill.approved` | A supplier bill is approved. | `supplierBillId`, `billNumber`, `supplierId`, `supplierName`, `totalCents`, `purchaseOrderIds` |
| `subscription.changed` | A distributor subscription changes. | `subscriptionId`, `connectorId`, `vendor`, `clientId`, `sku`, `productName`, `quantity`, `previousQuantity`, `status` |
| `subscription.cancel_deadline` | A subscription's NCE cancel deadline is near. | `subscriptionId`, `clientId`, `sku`, `productName`, `quantity`, `cancelDeadlineAt` |
| `sales_order.created`, `.fulfilled`, `.cancelled`, `.closed` | MSP plan. A sales order is created, delivered (once per delivery), canceled, or closed. | `salesOrderId`, `soNumber`, `clientId`, `clientName`, `status`, `totalCents` |
| `opportunity.stage_changed`, `.won`, `.lost` | MSP plan. A deal moves stage, is won, or is lost. | `opportunityId`, `name`, `clientId`, `prospectId`, `stageId`, `stageName`, `valueCents`. Company names only. |
| `ping` | You select **Send test**. | `endpointId` |

Events about assets are sent for your own register. On the MSP plan, that is
your practice's register, not each client's. Fields may be added to `data`
over time, and fields listed here are not removed.

## Verify

- **Send test** shows a **Delivered** `ping` under **Deliveries**, and your
  receiver's signature check passes.
- A request with the new API key answers `200`, and the key's **Last used**
  shows the time.

## Related

- [API reference](/api/)
- [Authentication](/api/authentication/)
- [Roles and permissions](/depot/roles-and-permissions/)
