Arkestro Webhooks API

# Verifying webhook signatures Arkestro signs **every** outbound webhook with **HMAC-SHA256**. The instructions below apply to all webhook events. Before you trust a delivery, verify the signature so you can be sure the request came from Arkestro and was not tampered with in transit. ## Headers Every delivery includes these headers: | Header | Description | | --- | --- | | `X-Arkestro-Signature` | One or more HMAC-SHA256 signatures of the request, each formatted `sha256=` and joined by commas with no whitespace (e.g. `sha256=5d61...,sha256=9af2...`). Multiple values appear during secret rotation — see below. | | `X-Arkestro-Timestamp` | The Unix timestamp (epoch **seconds**, as a string) at which the request was signed, e.g. `1748290800`. This value is part of the signed payload. | | `X-Arkestro-Idempotency-Key` | A UUID identifying the delivery **event**. It is generated once when the event fires and stays **constant across all retry attempts** of that delivery, so you can use it to deduplicate retries on your end. Unlike `request_id` in the body, which changes on every attempt, this value does not. It is **not** part of the signed payload. | ## How the signature is computed Arkestro builds a **signed payload** by concatenating the timestamp, a literal `.`, and the raw request body: ```text signed_payload = "{X-Arkestro-Timestamp}.{raw_request_body}" ``` It then computes `HMAC-SHA256(secret, signed_payload)` as a lowercase **hex** digest and prefixes it with `sha256=`. Folding the timestamp into the digest means a replayed request with a tampered timestamp will fail verification. ## How to verify a delivery 1. Read the `X-Arkestro-Timestamp` and `X-Arkestro-Signature` headers. 2. *(Recommended)* Reject the delivery if the timestamp is outside a tolerance window (e.g. ±5 minutes from now) to limit replay attacks. 3. Rebuild the signed payload as `"{timestamp}.{raw_body}"` using the **raw request bytes** — not a re-serialized or pretty-printed copy of the JSON. Re-encoding the body will change the bytes and break verification. 4. Compute `sha256=` + the hex HMAC-SHA256 of the signed payload using your signing secret. 5. Split `X-Arkestro-Signature` on `,` and check, using a **constant-time** comparison, whether your computed value matches any of the provided values. Accept the delivery if at least one matches. ## Secret rotation and multiple signatures `X-Arkestro-Signature` can contain **more than one** `sha256=` value. Arkestro signs each delivery with every secret that is currently active, including a previous secret that is still within its rotation grace period. This lets you rotate secrets with zero missed deliveries: - While both the old and new secret are active, Arkestro sends a signature for each. A verifier holding either secret will find a matching value. - Once you have switched to the new secret and the old one expires, only the new signature is sent. When verifying, always **split the header on commas and accept if any value matches** a digest computed with any secret you currently hold. Never assume a single signature. ## Code examples Each example reads the raw request body, rebuilds the signed payload, splits the signature header on commas, and compares each candidate against a locally computed digest using a constant-time comparison. `secrets` is the list of signing secrets you currently hold (one normally, two during a rotation). ### Node.js ```js const crypto = require('crypto'); // rawBody must be the exact bytes received (e.g. a string or Buffer), // not JSON.parse'd and re-stringified. function verifyWebhook(rawBody, headers, secrets, toleranceSec = 300) { const timestamp = headers['x-arkestro-timestamp']; const signatureHeader = headers['x-arkestro-signature']; if (!timestamp || !signatureHeader) return false; // Optional replay protection. const age = Math.floor(Date.now() / 1000) - Number(timestamp); if (!Number.isFinite(age) || Math.abs(age) > toleranceSec) return false; const signedPayload = `${timestamp}.${rawBody}`; const provided = signatureHeader.split(','); return secrets.some((secret) => { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex'); const expectedBuf = Buffer.from(expected); return provided.some((candidate) => { const candidateBuf = Buffer.from(candidate); return ( candidateBuf.length === expectedBuf.length && crypto.timingSafeEqual(candidateBuf, expectedBuf) ); }); }); } ``` ### Python ```python import hashlib import hmac import time def verify_webhook(raw_body: bytes, headers, secrets, tolerance_sec: int = 300) -> bool: timestamp = headers.get("X-Arkestro-Timestamp") signature_header = headers.get("X-Arkestro-Signature") if not timestamp or not signature_header: return False # Optional replay protection. Fail closed on a malformed timestamp header. try: age = abs(int(time.time()) - int(timestamp)) except (TypeError, ValueError): return False if age > tolerance_sec: return False # raw_body must be the exact bytes received. signed_payload = f"{timestamp}.".encode() + raw_body provided = signature_header.split(",") for secret in secrets: digest = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest() expected = f"sha256={digest}" if any(hmac.compare_digest(expected, candidate) for candidate in provided): return True return False ``` ### Ruby ```ruby require "openssl" require "active_support/security_utils" # raw_body must be the exact request body string received (e.g. request.raw_post). def verify_webhook(raw_body, headers, secrets, tolerance_sec: 300) timestamp = headers["X-Arkestro-Timestamp"] signature_header = headers["X-Arkestro-Signature"] return false if timestamp.nil? || signature_header.nil? # Optional replay protection. return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_sec signed_payload = "#{timestamp}.#{raw_body}" provided = signature_header.split(",") secrets.any? do |secret| digest = OpenSSL::HMAC.hexdigest("sha256", secret, signed_payload) expected = "sha256=#{digest}" provided.any? { |candidate| ActiveSupport::SecurityUtils.secure_compare(expected, candidate) } end end ``` # Responding to a delivery Your endpoint's HTTP response tells Arkestro whether the delivery succeeded. - **Acknowledge with a `2xx` status code.** Any `2xx` (e.g. `200`, `201`, `202`, `204`) is treated identically as success. The response body is ignored. - **Respond promptly.** Arkestro waits up to **10 seconds** for a response. Do any slow processing asynchronously (enqueue it and return `2xx` right away) so you don't exceed the timeout. - **Anything else is a failed delivery.** A non-`2xx` status code, a timeout, or a connection error is treated as a failure. Whether Arkestro retries depends on the status code — see the retry schedule below. In particular, `4xx` errors (except `429`) are treated as permanent failures and will **not** be retried. ## Retry schedule Arkestro retries failed deliveries automatically. The retry behavior depends on the failure type. **What gets retried:** `429`, `5xx`, transport errors (timeouts, connection failures). **What is not retried (delivery is discarded):** `4xx` (except `429`). Arkestro makes up to **10 retry attempts** using exponential backoff. If all attempts are exhausted without a `2xx` response, the delivery is permanently abandoned. The final retry occurs approximately **4–5 hours** after the initial attempt.

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/arkestro:arkestro-webhooks-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

arkestro-webhooks-api-openapi.yml Raw ↑
openapi: 3.2.0
info:
  version: '2.0'
  title: V2 Webhooks API
  description: 'The Arkestro API is used to manage aspects of your Arkestro instance without needing to have a user session.

    Our current API offering features endpoints that allow the creation and execution of an event.


    To get started, please generate an API Token.

    To generate an API Token your user must be an admin and you must request the feature be made available.

    Once enabled, you can go to your User Settings -> Personal Access Tokens and generate a new API token. This API token will be used in your request

    header as an X-Token.


    Once you have an API Token you can make your first request. A good starting point would be to request one of the index endpoints, such as:

    curl --location --request GET ''https://api.arkestro.com/api/v2/events'' --header ''Accept: application/json'' --header ''X-Token: '''
servers:
- url: https://api.arkestro.com
security:
- ApiKeyAuth: []
tags:
- name: Webhooks
  description: '# Verifying webhook signatures


    Arkestro signs **every** outbound webhook with **HMAC-SHA256**. The instructions

    below apply to all webhook events. Before you trust a delivery, verify the

    signature so you can be sure the request came from Arkestro and was not tampered

    with in transit. ## Headers


    Every delivery includes these headers:


    | Header | Description |

    | --- | --- |

    | `X-Arkestro-Signature` | One or more HMAC-SHA256 signatures of the request, each formatted `sha256=` and joined by commas with no whitespace (e.g. `sha256=5d61...,sha256=9af2...`). Multiple values appear during secret rotation — see below. |

    | `X-Arkestro-Timestamp` | The Unix timestamp (epoch **seconds**, as a string) at which the request was signed, e.g. `1748290800`. This value is part of the signed payload. |

    | `X-Arkestro-Idempotency-Key` | A UUID identifying the delivery **event**. It is generated once when the event fires and stays **constant across all retry attempts** of that delivery, so you can use it to deduplicate retries on your end. Unlike `request_id` in the body, which changes on every attempt, this value does not. It is **not** part of the signed payload. |


    ## How the signature is computed


    Arkestro builds a **signed payload** by concatenating the timestamp, a literal

    `.`, and the raw request body:


    ```text

    signed_payload = "{X-Arkestro-Timestamp}.{raw_request_body}"

    ```


    It then computes `HMAC-SHA256(secret, signed_payload)` as a lowercase **hex**

    digest and prefixes it with `sha256=`. Folding the timestamp into the digest

    means a replayed request with a tampered timestamp will fail verification. ## How to verify a delivery


    1. Read the `X-Arkestro-Timestamp` and `X-Arkestro-Signature` headers. 2. *(Recommended)* Reject the delivery if the timestamp is outside a tolerance

    window (e.g. ±5 minutes from now) to limit replay attacks. 3. Rebuild the signed payload as `"{timestamp}.{raw_body}"` using the **raw

    request bytes** — not a re-serialized or pretty-printed copy of the JSON. Re-encoding the body will change the bytes and break verification. 4. Compute `sha256=` + the hex HMAC-SHA256 of the signed payload using your

    signing secret. 5. Split `X-Arkestro-Signature` on `,` and check, using a **constant-time**

    comparison, whether your computed value matches any of the provided values. Accept the delivery if at least one matches. ## Secret rotation and multiple signatures


    `X-Arkestro-Signature` can contain **more than one** `sha256=` value. Arkestro

    signs each delivery with every secret that is currently active, including a

    previous secret that is still within its rotation grace period. This lets you rotate secrets with zero missed deliveries:


    - While both the old and new secret are active, Arkestro sends a signature for

    each. A verifier holding either secret will find a matching value. - Once you have switched to the new secret and the old one expires, only the new

    signature is sent. When verifying, always **split the header on commas and accept if any value

    matches** a digest computed with any secret you currently hold. Never assume a

    single signature. ## Code examples


    Each example reads the raw request body, rebuilds the signed payload, splits the

    signature header on commas, and compares each candidate against a locally

    computed digest using a constant-time comparison. `secrets` is the list of

    signing secrets you currently hold (one normally, two during a rotation). ### Node.js


    ```js

    const crypto = require(''crypto'');


    // rawBody must be the exact bytes received (e.g. a string or Buffer),

    // not JSON.parse''d and re-stringified. function verifyWebhook(rawBody, headers, secrets, toleranceSec = 300) {

    const timestamp = headers[''x-arkestro-timestamp''];

    const signatureHeader = headers[''x-arkestro-signature''];

    if (!timestamp || !signatureHeader) return false;


    // Optional replay protection.'
paths: {}
webhooks:
  award.granted:
    post:
      summary: award.granted
      operationId: awardGrantedWebhook
      security: []
      tags:
      - Webhooks
      description: 'Fired when an award is finalized on an event. The `data.award` payload

        is identical to the award returned by `GET /api/v2/events/{id}/award`,

        so webhook consumers and pollers of that endpoint always see the same

        shape.


        Every delivery is signed — see the introduction of this section for how

        to verify the signature headers, how to respond, and the retry

        schedule.'
      parameters:
      - name: X-Arkestro-Signature
        in: header
        required: true
        schema:
          type: string
        description: One or more HMAC-SHA256 signatures of the request, each formatted `sha256=<hex>` and joined by commas with no whitespace. Multiple values appear during secret rotation.
        example: sha256=5d61a7b19c1c1b2f9a4408c1a3ee7dcd2f1c5b7e9a0d3f6c8b2e4a1d7c9f0b3a
      - name: X-Arkestro-Timestamp
        in: header
        required: true
        schema:
          type: string
        description: The Unix timestamp (epoch seconds, as a string) at which the request was signed. Part of the signed payload.
        example: '1748290800'
      - name: X-Arkestro-Idempotency-Key
        in: header
        required: true
        schema:
          type: string
          format: uuid
        description: A UUID identifying the delivery event. It stays constant across all retry attempts of the delivery (unlike `request_id` in the body, which changes on every attempt), so use it to deduplicate retries.
        example: 0b8a4f7d-2e61-4c3b-9d05-8f6a1c2e4b7d
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: components/schemas/award_granted_webhook_payload.yaml
            examples:
              basic:
                value:
                  webhook_event: award.granted
                  request_id: 6f1c2b6e-9d3a-4f5b-8a90-2c7e4d1b5f03
                  data:
                    award:
                      id: 501
                      award_format: by_line_item
                      awarded_at: '2026-04-01T19:07:00Z'
                      event:
                        id: 118996
                        name: ERF013338 200007902
                        currency: USD
                        external_id: ERF013338
                        business_unit:
                          id: 1
                          name: Midwest
                          unique_name: Midwest Processing Co 123
                        custom_value_fields:
                        - id: 1
                          type_text: Site ID
                          value_text: CSD-1053
                      awarded_line_items:
                      - line_item_id: 7734
                        quote_submission_id: 9821
                        quote_submission_lot_id: 3307
                        quote_submission_line_item_id: 44120
                        name: hammers
                        total: '1000.00'
                        quoted_price_per_unit: '100.00'
                        awarded_quantity_of_units: '10'
                        quoted_quantity_of_units: '10'
                        unit_of_measure: each
                        supplier_provided_price: '2050.00'
                        supplier_provided_discount: '5.0'
                        quoting_currency: MXN
                        supplier_contact:
                          id: 3301
                          first_name: Hank
                          last_name: Hammersmith
                          email: hank@acmehammers.com
                          external_id: contact-77
                          supplier_organization:
                            id: 205
                            name: Acme Hammers Inc.
                            external_id: sup123
      responses:
        2XX:
          description: Acknowledge the delivery with any `2xx` status code within 10 seconds; the response body is ignored. `429` and `5xx` responses, timeouts, and connection errors are retried with exponential backoff; any other non-`2xx` response discards the delivery permanently.
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Token