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 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
Section titled “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.
- A webhook receiver must be reachable from the internet over HTTPS on port 443, with a valid certificate.
Create an API key
Section titled “Create an API key”- Select Settings > Integrations > API keys, then Create key.
- Enter a Name that says what uses the key, such as Accounting sync.
- Choose the Role the key acts as. The key can do exactly what this role can. Any role except Owner can be chosen.
- Optionally, choose when it Expires. The key stops working at the end of that day. Leave it empty for no expiry.
- 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. - Select Create key, and confirm it is you with a second factor.
- 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.
Use an API key
Section titled “Use an API key”Send the key in the Authorization header of each request:
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
Section titled “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.
Manage API keys
Section titled “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
Section titled “Add a webhook”- Select Settings > Integrations > Webhooks, then Add webhook.
- Enter a Name, and the URL Outpost posts to. It must start
https://, and use the standard port. - Under Events, select the events to send, or Select all.
- Leave Enabled selected, and select Add webhook.
- Copy the Signing secret. It is shown once.
- Open the webhook and select Send test. A
pingevent is delivered, and appears under Deliveries.
What a delivery looks like
Section titled “What a delivery looks like”Outpost sends each event as a POST with a JSON body:
{ "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
Section titled “Verify the signature”Check every delivery before you act on it:
- Read
tand everyv1value fromX-Outpost-Signature. During a secret rotation there may be twov1values. - Join the
tvalue, a full stop, and the raw request body, byte for byte:<t>.<body>. - Compute the HMAC-SHA256 of that string with the webhook’s whole signing
secret,
whsec_prefix included, as lowercase hex. - Accept the delivery when it equals any
v1value, compared in constant time. - Refuse it when
tis more than 5 minutes from your clock.
In Python:
import hashlibimport hmacimport 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
Section titled “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
Section titled “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
Section titled “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
Section titled “Verify”- Send test shows a Delivered
pingunder 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
Section titled “Related”Content is licensed CC BY 4.0. Code samples are MIT. Outpost and the Outpost mark are trademarks of Outpost Business Solutions, PBC and are not covered by either license.