diff --git a/docs.json b/docs.json index d8974d83..7086b1ac 100644 --- a/docs.json +++ b/docs.json @@ -454,7 +454,15 @@ "group": "Reference", "pages": [ "reference/resource-limits", - "reference/webhooks", + { + "group": "Webhooks", + "pages": [ + "reference/webhooks/overview", + "reference/webhooks/quickstart", + "reference/webhooks/verify-signatures", + "reference/webhooks/reference" + ] + }, "reference/migration-guide", "reference/faq" ] @@ -1098,7 +1106,12 @@ }, { "source": "/integration-guides/webhooks", - "destination": "/reference/webhooks", + "destination": "/reference/webhooks/overview", + "permanent": true + }, + { + "source": "/reference/webhooks", + "destination": "/reference/webhooks/overview", "permanent": true }, { @@ -2098,7 +2111,7 @@ }, { "source": "/developer-reference/webhooks", - "destination": "/reference/webhooks", + "destination": "/reference/webhooks/overview", "permanent": true }, { diff --git a/reference/webhooks.mdx b/reference/webhooks.mdx deleted file mode 100644 index 5d0d4fc5..00000000 --- a/reference/webhooks.mdx +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: "Activity webhooks" -description: "Webhooks provide a powerful mechanism to receive notifications about activity requests in your Turnkey organization. Additionally, you'll be able to receive all activity requests for both the parent organization and all its child organizations. This functionality can be enabled via the organization feature capabilities of our platform, as detailed in the section on [organization features](/features/organizations#features)." -sidebarTitle: "Webhooks" ---- - -This guide is designed to walk you through the process of setting up webhooks, from environment preparation to verification of successful event capturing. - -## Prerequisites - -Before diving into webhook configuration, ensure you have completed the necessary preliminary steps outlined in our [Quickstart Guide](/get-started/quickstart#create-your-turnkey-organization). This guide will assist you in setting up a new organization and installing the Turnkey CLI. Note: We'll create a new API Key for testing webhooks below. - -## Environment setup - -Begin by setting the necessary environment variables: - -```bash -ORGANIZATION_ID=KEY_NAME=webhook-test -``` - -### API key generation - -Generate a new API key using the Turnkey CLI with the following command: - -```bash -turnkey generate api-key --organization $ORGANIZATION_ID --key-name $KEY_NAME -``` - -### Ngrok installation and setup - -Ngrok is a handy tool that allows you to expose your local server to the internet. Follow these steps to set it up: - - - Download Ngrok from [their website](https://ngrok.com/download). - - Follow the provided instructions to install Ngrok and configure your auth - token. - - - -### Local server setup - -Open a new terminal window and set up a local server to listen for incoming webhook events: - -```bash -nc -l 8000 -``` - -### Ngrok tunneling - -In another terminal, initiate Ngrok to forward HTTP requests to your local server: - -```bash -ngrok http 8000 -``` - -Here's an output of the above command: - -```bash -Session Status online -Account Satoshi Nakamoto (Plan: Free) -Update update available (version 3.7.0, Ctrl-U to update) -Version 3.6.0 -Region United States (us) -Latency 22ms -Web Interface http://127.0.0.1:4041 -Forwarding https://04b2-121-74-183-35.ngrok-free.app -> http://localhost:8000 - -Connections ttl opn rt1 rt5 p50 p90 - 0 0 0.00 0.00 0.00 0.00 -``` - -Save the ngrok URL as an environment variable: - -```bash -WEBHOOK_URL=https://04•••35.ngrok-free.app # Replace with the URL provided by ngrok -``` - -### Verifying Ngrok setup - -To ensure Ngrok is correctly forwarding requests, perform a test using curl: - -```bash -curl -X POST $WEBHOOK_URL -d "{}" -``` - -Example output: - -```bash -POST / HTTP/1.1 -Host:04b2-121-74-183-35.ngrok-free.app -User-Agent: curl/8.4.0 -Content-Length: 2 -Accept: */* -Content-Type: application/x-www-form-urlencoded -X-Forwarded-For: 195.88.127.47 -X-Forwarded-Host: 04b2-121-74-183-35.ngrok-free.app -X-Forwarded-Proto: https -Accept-Encoding: gzip -{} -``` - -After executing this command, you should see the request appear in the terminal where `nc` is running. Terminate the `nc` session by pressing CTRL+C and restart it by rerunning the `nc` command. - -## Configuring the webhook URL - -Set your webhook URL using the Turnkey CLI with the following command: - -```bash -turnkey request --path /public/v1/submit/set_organization_feature --body '{ - "timestampMs": "'"$(date +%s)"'000", - "type": "ACTIVITY_TYPE_SET_ORGANIZATION_FEATURE", - "organizationId": "'"$ORGANIZATION_ID"'", - "parameters": { - "name": "FEATURE_NAME_WEBHOOK", - "value": "'"$WEBHOOK_URL"'" - } -}' --key-name=$KEY_NAME -``` - -### Testing your webhook - -Assuming the previous request executed successfully it's time to test out your webhook! In order to verify that your webhook is correctly configured and receiving data, we can simply execute the previous turnkey request command again which creates a new activity request that will be captured by your webhook. Monitor the terminal with `nc` running to observe the incoming webhook data. - -## Conclusion - -By following these steps, you should now have a functioning webhook setup that captures all activity requests for your organization and its sub-organizations. If you encounter any issues or have feedback about this feature, reach out on [slack](https://join.slack.com/t/clubturnkey/shared_invite/zt-3aemp2g38-zIh4V~3vNpbX5PsSmkKxcQ)! diff --git a/reference/webhooks/overview.mdx b/reference/webhooks/overview.mdx new file mode 100644 index 00000000..1b828316 --- /dev/null +++ b/reference/webhooks/overview.mdx @@ -0,0 +1,55 @@ +--- +title: "Webhooks" +description: "Receive signed, real-time notifications for events in your Turnkey organization." +sidebarTitle: "Overview" +--- + +Webhooks let your application react to Turnkey events without polling. You register an HTTPS endpoint, choose the event types you want, and Turnkey sends signed `POST` requests when matching events occur. + +Use webhooks to update an internal activity feed, start downstream reconciliation when balances change, or track transaction status as submitted transactions move through broadcast and confirmation. + +## Event types + +| Event type | Description | Scope | +| --- | --- | --- | +| `ACTIVITY_UPDATES` | Activity status updates for activities in the configured organization. | Organization-scoped | +| `BALANCE_CONFIRMED_UPDATES` | Balance changes when a transaction is first seen in a block onchain. | Billing organization-scoped | +| `BALANCE_FINALIZED_UPDATES` | Balance changes after the containing block reaches the finalization threshold. | Billing organization-scoped | +| `SEND_TRANSACTION_STATUS_UPDATES` | Status changes for submitted transactions, such as broadcasting, included, or failed. | Billing organization-scoped | + +## Organization scope + +Activity webhooks are configured on the organization where activities occur. + +Balance and send-transaction-status webhooks must be configured from the billing organization. In a parent/sub-organization setup, create these endpoints from the parent billing organization so the endpoint can receive events for wallet accounts and transaction activity under that billing org. + +## Delivery model + +Turnkey sends each delivery as an HTTPS `POST` request with an `application/json` body. Your receiver should verify the signature, accept the event, enqueue any slow work, and return a `2xx` response quickly. + +Network errors and `5xx` responses may be retried. Redirects, `4xx` responses, and `429` responses are treated as terminal delivery failures. + +## Security model + +Turnkey validates webhook endpoint URLs when endpoints are created or updated: + +- URLs must use HTTPS. +- Hosts must resolve to public destinations. +- Localhost, private ranges, link-local addresses, metadata endpoints, multicast, and unspecified addresses are rejected. +- Redirects are not followed during delivery. + +Turnkey also signs Webhooks V2 deliveries with Ed25519. Receivers should verify the signature over the exact raw request body before parsing JSON. + +## Next steps + + + + Create an endpoint with webhook.site, trigger a harmless event, and inspect the delivery. + + + Verify raw webhook requests with `@turnkey/crypto`. + + + Review headers, payloads, retries, URL validation, and endpoint operations. + + diff --git a/reference/webhooks/quickstart.mdx b/reference/webhooks/quickstart.mdx new file mode 100644 index 00000000..c7965013 --- /dev/null +++ b/reference/webhooks/quickstart.mdx @@ -0,0 +1,199 @@ +--- +title: "Webhooks quickstart" +description: "Create a Turnkey webhook endpoint with webhook.site and inspect your first delivery." +sidebarTitle: "Quickstart" +--- + +import { WebhookSiteUrlGenerator } from '/snippets/webhook-site-url-generator.mdx' + +This walkthrough uses [webhook.site](https://webhook.site) as a temporary receiver so you can see the exact headers and JSON body Turnkey sends. + + +Use webhook.site only for testing. Production webhook endpoints should be owned by your application, verify Turnkey signatures, and avoid logging sensitive payloads. + + +## 1. Create a test receiver + +Use a temporary receiver for this quickstart, then replace it with your own HTTPS endpoint before sending production traffic. + + + + Click **Generate URL** to create a temporary Webhook.site URL for this browser. The docs save it locally so you can refresh the page or return to this quickstart without losing the value. + + + + + Use this URL only for local testing. Webhook.site stores captured requests so you can inspect them. Do not send production webhook traffic or sensitive payloads to a shared test receiver. + + + + + Go to [https://webhook.site](https://webhook.site). Webhook.site creates a unique URL for your browser session. + + Copy the value labeled **Your unique URL**. It should look like: + + ```text + https://webhook.site/00000000-0000-0000-0000-000000000000 + ``` + + Set it as an environment variable for the examples below: + + ```bash + export WEBHOOK_URL="https://webhook.site/00000000-0000-0000-0000-000000000000" + ``` + + Keep the webhook.site tab open. New deliveries will appear in the request list on the left. + + + +If the generated URL button fails in your browser, use the existing URL tab. Browser-based token creation depends on Webhook.site allowing docs-origin requests. + +## 2. Create a Turnkey webhook endpoint + +Choose one creation method. + + + + In the Turnkey Dashboard, open **Webhooks**. + + 1. In **Webhook Endpoints**, select **New webhook**. + 2. In **Create Webhook**, enter a **Name**, such as `Webhook.site test`. + 3. Paste your webhook.site URL into **Destination URL**. + 4. Under **Subscriptions**, choose one or more event types: + - **Activities** for `ACTIVITY_UPDATES` + - **Confirmed Balances** for `BALANCE_CONFIRMED_UPDATES` + - **Finalized Balances** for `BALANCE_FINALIZED_UPDATES` + - **Transactions** for `SEND_TRANSACTION_STATUS_UPDATES` + 5. Select **Create Webhook**. + + Creating a webhook endpoint is a signed write activity. Depending on your organization's policy, the Dashboard may prompt you to approve **Webhook Creation** before the endpoint is created. + + + + Install `@turnkey/sdk-server` and create the endpoint from a server-side environment. + + ```ts + import { Turnkey } from "@turnkey/sdk-server"; + + const turnkey = new Turnkey({ + apiBaseUrl: "https://api.turnkey.com", + apiPublicKey: process.env.API_PUBLIC_KEY!, + apiPrivateKey: process.env.API_PRIVATE_KEY!, + defaultOrganizationId: process.env.ORGANIZATION_ID!, + }); + + const response = await turnkey.apiClient().createWebhookEndpoint({ + organizationId: process.env.ORGANIZATION_ID!, + name: "Webhook.site test", + url: process.env.WEBHOOK_URL!, + subscriptions: [{ eventType: "ACTIVITY_UPDATES" }], + }); + + console.log(response.endpointId); + ``` + + For balance or transaction status webhooks, run this from the billing organization and change the subscription: + + ```ts + subscriptions: [{ eventType: "BALANCE_CONFIRMED_UPDATES" }]; + ``` + + + + The Turnkey CLI is implemented in [`tkhq/tkcli`](https://github.com/tkhq/tkcli). It does not have dedicated webhook subcommands today, but `turnkey request` can call the public API directly. + + Create an endpoint: + + ```bash + export ORGANIZATION_ID="" + export WEBHOOK_URL="https://webhook.site/" + + turnkey request \ + --path /public/v1/submit/create_webhook_endpoint \ + --body '{ + "type": "ACTIVITY_TYPE_CREATE_WEBHOOK_ENDPOINT", + "timestampMs": "'"$(date +%s)"'000", + "organizationId": "'"$ORGANIZATION_ID"'", + "parameters": { + "name": "Webhook.site test", + "url": "'"$WEBHOOK_URL"'", + "subscriptions": [ + { "eventType": "ACTIVITY_UPDATES" } + ] + } + }' + ``` + + List endpoints: + + ```bash + turnkey request \ + --path /public/v1/query/list_webhook_endpoints \ + --body '{ + "organizationId": "'"$ORGANIZATION_ID"'" + }' + ``` + + Create, update, and delete are signed write activities and may require approval depending on your organization's policy. + + + +## 3. Trigger a harmless event + +For an activity webhook, update the endpoint name you just created. This avoids creating a duplicate endpoint with the same URL. + + + + In **Webhooks**, edit the endpoint and change the **Name**, for example from `Webhook.site test` to `Webhook.site test 2`. Save the webhook. + + + + ```ts + await turnkey.apiClient().updateWebhookEndpoint({ + organizationId: process.env.ORGANIZATION_ID!, + endpointId: "", + name: `Webhook.site test ${Date.now()}`, + }); + ``` + + + + ```bash + export ENDPOINT_ID="" + + turnkey request \ + --path /public/v1/submit/update_webhook_endpoint \ + --body '{ + "type": "ACTIVITY_TYPE_UPDATE_WEBHOOK_ENDPOINT", + "timestampMs": "'"$(date +%s)"'000", + "organizationId": "'"$ORGANIZATION_ID"'", + "parameters": { + "endpointId": "'"$ENDPOINT_ID"'", + "name": "Webhook.site test '"$(date +%s)"'" + } + }' + ``` + + + +## 4. Inspect the delivery + +Return to webhook.site. You should see a new `POST` request. + +Open it and inspect: + +- Headers such as `X-Turnkey-Organization-Id`, `X-Turnkey-Event-Type`, `X-Turnkey-Timestamp`, `X-Turnkey-Webhook-Version`, and the signature headers. +- The JSON body. For `ACTIVITY_UPDATES`, the body contains the activity object that changed. +- The response status webhook.site returned to Turnkey. + +## 5. Move to production + +When you replace webhook.site with your production endpoint: + +- Verify signatures using the exact raw request body before parsing JSON. +- Return `2xx` only after accepting the event. +- Queue slow work and respond quickly. +- Deduplicate downstream work. For balance and transaction status events, use `msg.idempotencyKey`; for activity events, use the activity ID and webhook event ID. +- Monitor non-`2xx` responses and receiver timeouts. + +Continue with [Verify signatures](/reference/webhooks/verify-signatures) before sending production traffic to your receiver. diff --git a/reference/webhooks/reference.mdx b/reference/webhooks/reference.mdx new file mode 100644 index 00000000..843082ef --- /dev/null +++ b/reference/webhooks/reference.mdx @@ -0,0 +1,205 @@ +--- +title: "Webhooks reference" +description: "Technical contract for Turnkey Webhooks V2 delivery, signatures, payloads, retries, and endpoint management." +sidebarTitle: "Reference" +--- + +## Event types + +| Event type | Public payload `type` | Scope | Notes | +| --- | --- | --- | --- | +| `ACTIVITY_UPDATES` | Activity object `type`, such as `ACTIVITY_TYPE_UPDATE_WEBHOOK_ENDPOINT` | Organization-scoped | Configure on the organization where activities occur. | +| `BALANCE_CONFIRMED_UPDATES` | `balances:confirmed` | Billing organization-scoped | Emitted when a supported balance change is first seen in a block. | +| `BALANCE_FINALIZED_UPDATES` | `balances:finalized` | Billing organization-scoped | Emitted when the block reaches finalization. | +| `SEND_TRANSACTION_STATUS_UPDATES` | `transaction:status` | Billing organization-scoped | Emitted when send transaction status changes. | + +Balance and send-transaction-status webhook endpoints must be managed from the billing organization. Sub-organizations cannot manage billing-scoped endpoints. + +## Endpoint operations + +| Operation | API reference | Path | Notes | +| --- | --- | --- | --- | +| Create | [Create webhook endpoint](/api-reference/activities/create-webhook-endpoint) | `/public/v1/submit/create_webhook_endpoint` | Requires `url`, `name`, and at least one subscription. | +| List | [List webhook endpoints](/api-reference/queries/list-webhook-endpoints) | `/public/v1/query/list_webhook_endpoints` | Returns endpoints and their subscriptions. | +| Update | [Update webhook endpoint](/api-reference/activities/update-webhook-endpoint) | `/public/v1/submit/update_webhook_endpoint` | Updates `url`, `name`, or `isActive`. | +| Delete | [Delete webhook endpoint](/api-reference/activities/delete-webhook-endpoint) | `/public/v1/submit/delete_webhook_endpoint` | Deletes an endpoint and its subscriptions. | + +Create, update, and delete are signed write activities: + +```text +ACTIVITY_TYPE_CREATE_WEBHOOK_ENDPOINT +ACTIVITY_TYPE_UPDATE_WEBHOOK_ENDPOINT +ACTIVITY_TYPE_DELETE_WEBHOOK_ENDPOINT +``` + +Root users can approve these by default. Use [policies](/features/policies/overview) to delegate webhook management to non-root users. + +## Endpoint URL validation + +Turnkey validates endpoint URLs when webhook endpoints are created or updated. + +| Rule | Behavior | +| --- | --- | +| Scheme | HTTPS is required. | +| Host | A host is required. | +| User info | URLs with user info are rejected. | +| DNS | Hostnames must resolve to public destination IPs. | +| Blocked destinations | Localhost, private ranges, link-local addresses, metadata endpoints, multicast, and unspecified addresses are rejected. | +| Redirects | Redirects are not followed during delivery. | + +## Delivery + +Turnkey sends each delivery as an HTTPS `POST` request. The body is JSON and `Content-Type` defaults to `application/json`. + +Receivers should: + +- Verify the signature over the raw request body. +- Persist or enqueue the event. +- Return `2xx` quickly after accepting the event. +- Process slow work asynchronously. + +## Delivery headers + +| Header | Description | +| --- | --- | +| `X-Turnkey-Organization-Id` | Organization used for webhook routing and delivery. For billing-scoped events, this is the billing organization. | +| `X-Turnkey-Event-Type` | Subscription event type, such as `ACTIVITY_UPDATES` or `BALANCE_CONFIRMED_UPDATES`. | +| `X-Turnkey-Timestamp` | Unix timestamp in milliseconds for the delivery attempt. | +| `X-Turnkey-Webhook-Version` | Webhook delivery contract version. Current value: `1`. | +| `X-Turnkey-Event-Id` | Stable event identifier for the webhook event. | +| `X-Turnkey-Signature-Key-Id` | Turnkey webhook signing key ID. | +| `X-Turnkey-Signature-Algorithm` | Signature algorithm. Current value: `ed25519`. | +| `X-Turnkey-Signature-Version` | Signature contract version. Current value: `v1`. | +| `X-Turnkey-Signature` | Hex-encoded Ed25519 signature. | + +## Signed input + +The signed input is: + +```text +v1.ed25519.... +``` + +`` is `X-Turnkey-Timestamp`, `` is `X-Turnkey-Event-Id`, and `` is the exact request body bytes Turnkey sent. + +## Retry behavior + +| Outcome | Delivery result | +| --- | --- | +| `2xx` | Success. | +| Network error | Retryable. | +| Timeout | Retryable while retry budget remains. | +| `5xx` | Retryable. | +| `3xx` | Terminal failure. Redirects are not followed. | +| `4xx` | Terminal failure. | +| `429` | Terminal failure. | + +Retry schedules and attempt counts are implementation details and may change. Signed retries receive a fresh delivery timestamp and signature. + +## Ordering and idempotency + +Webhooks V2 ordering is scoped to a single `billing_org_id:endpoint_id` delivery lane. There is no ordering guarantee across endpoints, even within the same billing organization. + +Use idempotent processing downstream. Balance and transaction-status payloads expose `msg.idempotencyKey`; activity update receivers should deduplicate on the activity ID plus the webhook event ID or activity update timestamp. + +## Payload examples + +### Activity update + +`ACTIVITY_UPDATES` deliveries contain the external activity object for the activity that changed. + +```json +{ + "id": "018f2f0f-0000-7000-9000-000000000000", + "organizationId": "95dfcd47-99bb-4433-9126-1524110d68e6", + "status": "ACTIVITY_STATUS_COMPLETED", + "type": "ACTIVITY_TYPE_UPDATE_WEBHOOK_ENDPOINT", + "intent": { + "updateWebhookEndpointIntent": { + "endpointId": "018f2f0f-1111-7000-9000-000000000000", + "name": "Webhook.site test 2" + } + }, + "result": { + "updateWebhookEndpointResult": { + "endpointId": "018f2f0f-1111-7000-9000-000000000000" + } + }, + "votes": [], + "fingerprint": "activity-fingerprint", + "canApprove": false, + "canReject": false, + "createdAt": "2026-05-22T19:09:31.000Z", + "updatedAt": "2026-05-22T19:09:35.000Z" +} +``` + +### Balance update + +```json +{ + "type": "balances:confirmed", + "organizationId": "95dfcd47-99bb-4433-9126-1524110d68e6", + "parentOrganizationId": "95dfcd47-99bb-4433-9126-1524110d68e6", + "msg": { + "operation": "deposit", + "caip2": "eip155:8453", + "txHash": "0x5b6901be92e69781a7ce401dd9a2910e1f49aa77a5bdedcd2a23c8d563d88b24", + "address": "0x3400e577153101863F39ba41f7Fd49Bbea011628", + "idempotencyKey": "d3b8cef0ad7479433783c5707da9ded4fee9b254b4638f44758a2141c49416b7:balances:confirmed", + "asset": { + "symbol": "ETH", + "name": "Ethereum", + "decimals": 18, + "caip19": "eip155:8453/slip44:60", + "amount": "4793760441409" + }, + "block": { + "number": 46343814, + "hash": "0x41a4e8d444e5410f83c1ac35c838c7d8be1e3d6f32a35a04c72096adae74d095", + "timestamp": "2026-05-22T19:09:35Z" + } + } +} +``` + +`operation` is `deposit` or `withdraw`. A single transaction can produce multiple balance webhook deliveries if it changes multiple tracked addresses or assets. Balance webhooks are emitted only for supported assets. + +### Send transaction status update + +```json +{ + "type": "transaction:status", + "organizationId": "9e8d7c6b-aaaa-bbbb-cccc-ddddeeee0000", + "parentOrganizationId": "9e8d7c6b-aaaa-bbbb-cccc-ddddeeee0000", + "msg": { + "sendTransactionStatusId": "f3a2b1c0-1234-5678-abcd-ef0123456789", + "activityId": "a1b2c3d4-0000-1111-2222-333344445555", + "status": "INCLUDED", + "caip2": "eip155:1", + "idempotencyKey": "7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8", + "timestamp": 1746000042, + "txHash": "0xabc123def456abc123def456abc123def456abc123def456abc123def456abc1" + } +} +``` + +Fields present in `msg` depend on status: + +| Status | Additional fields | +| --- | --- | +| `BROADCASTING` | No `txHash` or `error`. | +| `INCLUDED` | Includes `txHash`. If the transaction reverted onchain, `error` may also be present. | +| `FAILED` | Includes `error`; no `txHash` because the transaction did not land onchain. | + +## Troubleshooting + +| Symptom | What to check | +| --- | --- | +| Non-HTTPS URL rejected | Use an `https://` URL. | +| Private, local, or metadata destination rejected | Use a public receiver. Localhost, private IPs, link-local addresses, and metadata endpoints are blocked. | +| Receiver returns non-`2xx` | Return `2xx` only after accepting the event. `3xx`, `4xx`, and `429` are terminal failures; `5xx` may be retried. | +| Receiver times out | Verify quickly, enqueue work, and respond before your handler exceeds the delivery timeout. | +| Signature verification fails | Verify the exact raw request body before JSON parsing. Do not re-stringify parsed JSON. | +| Balance or transaction-status events do not arrive | Confirm the endpoint was created from the billing organization and subscribed to the correct event type. | +| Duplicate downstream work | Use idempotent processing keyed by `msg.idempotencyKey` where present, or the activity ID and webhook event ID for activity updates. | diff --git a/reference/webhooks/verify-signatures.mdx b/reference/webhooks/verify-signatures.mdx new file mode 100644 index 00000000..a253ef9b --- /dev/null +++ b/reference/webhooks/verify-signatures.mdx @@ -0,0 +1,280 @@ +--- +title: "Verify webhook signatures" +description: "Verify Turnkey Webhooks V2 signatures with caller-provided verification keys." +sidebarTitle: "Verify signatures" +--- + +Turnkey signs Webhooks V2 deliveries with Ed25519. Verify the signature before parsing or trusting the JSON payload. + +The signature covers the signature contract fields and the exact raw request body: + +```text +v1.ed25519.... +``` + + +Verification must use the raw request body that Turnkey sent. Re-serializing parsed JSON, changing whitespace, or changing key order changes the signed input. + + +## Verification keys + +`@turnkey/crypto` verifies against caller-provided verification keys: + +```ts +const verificationKeys = [ + { + keyId: process.env.TURNKEY_WEBHOOK_KEY_ID!, + publicKey: process.env.TURNKEY_WEBHOOK_PUBLIC_KEY!, + algorithm: "ed25519" as const, + }, +]; +``` + +Use the webhook signing key ID and public key provided by Turnkey for your environment. The public acquisition path for these values should be confirmed with product before publishing this page broadly. + +The helper does not discover keys automatically and does not implement JWKS, refresh behavior, discovery endpoints, or server-side key management. + +## Minimal example + +```ts +import { + TurnkeyWebhookVerificationFailureReasons, + verifyTurnkeyWebhookSignature, +} from "@turnkey/crypto"; + +const rawBody = await request.text(); + +const result = verifyTurnkeyWebhookSignature({ + headers: request.headers, + body: rawBody, + verificationKeys: [ + { + keyId: process.env.TURNKEY_WEBHOOK_KEY_ID!, + publicKey: process.env.TURNKEY_WEBHOOK_PUBLIC_KEY!, + algorithm: "ed25519", + }, + ], + maxTimestampAgeMs: 5 * 60 * 1000, +}); + +if (!result.ok) { + if (result.reason === TurnkeyWebhookVerificationFailureReasons.StaleTimestamp) { + console.warn("Rejected stale Turnkey webhook"); + } + + return new Response("Invalid signature", { status: 401 }); +} + +const payload = JSON.parse(rawBody); +``` + +`verifyTurnkeyWebhookSignature` accepts: + +| Parameter | Type | +| --- | --- | +| `headers` | `Headers` or `Record<string, string | string[] | undefined>` | +| `body` | `string`, `Uint8Array`, or `ArrayBuffer` | +| `verificationKeys` | `{ keyId, publicKey, algorithm: "ed25519" }[]` | +| `maxTimestampAgeMs` | Replay window in milliseconds. Use `5 * 60 * 1000`. | +| `nowMs` | Optional current time override in milliseconds. | + +Successful verification returns: + +```ts +{ ok: true; eventId: string; keyId: string; timestampMs: number } +``` + +Failed verification returns: + +```ts +{ + ok: false; + reason: TurnkeyWebhookVerificationFailureReason; + headerName?: string; +} +``` + +Compare `reason` against the `TurnkeyWebhookVerificationFailureReasons` constants instead of hardcoding strings. + +## Runtime examples + + + + ```ts + import { + verifyTurnkeyWebhookSignature, + } from "@turnkey/crypto"; + import { NextResponse } from "next/server"; + + export const runtime = "nodejs"; + + export async function POST(request: Request) { + const rawBody = await request.text(); + + const result = verifyTurnkeyWebhookSignature({ + headers: request.headers, + body: rawBody, + verificationKeys: [ + { + keyId: process.env.TURNKEY_WEBHOOK_KEY_ID!, + publicKey: process.env.TURNKEY_WEBHOOK_PUBLIC_KEY!, + algorithm: "ed25519", + }, + ], + maxTimestampAgeMs: 5 * 60 * 1000, + }); + + if (!result.ok) { + return NextResponse.json( + { error: "Invalid Turnkey webhook", reason: result.reason }, + { status: 401 } + ); + } + + const payload = JSON.parse(rawBody); + + return NextResponse.json({ ok: true, eventId: result.eventId }); + } + ``` + + + + ```ts + import { verifyTurnkeyWebhookSignature } from "@turnkey/crypto"; + + export default { + async fetch(request: Request, env: Env): Promise { + const rawBody = await request.arrayBuffer(); + + const result = verifyTurnkeyWebhookSignature({ + headers: request.headers, + body: rawBody, + verificationKeys: [ + { + keyId: env.TURNKEY_WEBHOOK_KEY_ID, + publicKey: env.TURNKEY_WEBHOOK_PUBLIC_KEY, + algorithm: "ed25519", + }, + ], + maxTimestampAgeMs: 5 * 60 * 1000, + }); + + if (!result.ok) { + return new Response("Invalid Turnkey webhook", { status: 401 }); + } + + const payload = JSON.parse(new TextDecoder().decode(rawBody)); + + return Response.json({ ok: true, eventId: result.eventId }); + }, + }; + ``` + + + + ```ts + import express from "express"; + import { verifyTurnkeyWebhookSignature } from "@turnkey/crypto"; + + const app = express(); + + app.post( + "/webhooks/turnkey", + express.raw({ type: "application/json" }), + (req, res) => { + const rawBody = req.body as Uint8Array; + + const result = verifyTurnkeyWebhookSignature({ + headers: req.headers, + body: rawBody, + verificationKeys: [ + { + keyId: process.env.TURNKEY_WEBHOOK_KEY_ID!, + publicKey: process.env.TURNKEY_WEBHOOK_PUBLIC_KEY!, + algorithm: "ed25519", + }, + ], + maxTimestampAgeMs: 5 * 60 * 1000, + }); + + if (!result.ok) { + return res.status(401).json({ + error: "Invalid Turnkey webhook", + reason: result.reason, + }); + } + + const payload = JSON.parse(Buffer.from(rawBody).toString("utf8")); + + return res.status(200).json({ ok: true, eventId: result.eventId }); + } + ); + ``` + + + + ```ts + import Fastify from "fastify"; + import { verifyTurnkeyWebhookSignature } from "@turnkey/crypto"; + + const fastify = Fastify(); + + fastify.addContentTypeParser( + "application/json", + { parseAs: "buffer" }, + (_request, body, done) => done(null, body) + ); + + fastify.post("/webhooks/turnkey", async (request, reply) => { + const rawBody = request.body as Uint8Array; + + const result = verifyTurnkeyWebhookSignature({ + headers: request.headers, + body: rawBody, + verificationKeys: [ + { + keyId: process.env.TURNKEY_WEBHOOK_KEY_ID!, + publicKey: process.env.TURNKEY_WEBHOOK_PUBLIC_KEY!, + algorithm: "ed25519", + }, + ], + maxTimestampAgeMs: 5 * 60 * 1000, + }); + + if (!result.ok) { + return reply.code(401).send({ + error: "Invalid Turnkey webhook", + reason: result.reason, + }); + } + + const payload = JSON.parse(Buffer.from(rawBody).toString("utf8")); + + return reply.send({ ok: true, eventId: result.eventId }); + }); + ``` + + + +## Verification failures + +Return a non-`2xx` response when verification fails. Log the failure reason, but avoid logging the raw body unless your logging pipeline is approved for webhook payloads. + +Common failure reasons include: + +| Reason | What to check | +| --- | --- | +| `InvalidMaxTimestampAge` | `maxTimestampAgeMs` must be a finite, non-negative number. | +| `InvalidNow` | `nowMs`, when provided, must be finite. | +| `MissingHeader` | One of the required signature headers was not present. Check `headerName`. | +| `InvalidTimestamp` or `StaleTimestamp` | The timestamp header was malformed or outside your replay window. Check clock skew. | +| `MissingKey` | No caller-provided verification key matched the `X-Turnkey-Signature-Key-Id` header. | +| `InvalidVerificationKeyAlgorithm` | A verification key declared an unsupported algorithm. Use `ed25519`. | +| `InvalidVerificationKey` | The configured public key is not a valid hex-encoded 32-byte Ed25519 public key. | +| `UnsupportedSignatureAlgorithm` | The algorithm header was not `ed25519`. | +| `UnsupportedSignatureVersion` | The signature version header was not `v1`. | +| `InvalidSignature` | The signature was malformed or did not verify over the exact raw body. | + +Signature verification proves the delivery came from a holder of the Turnkey webhook signing key and that the raw body was not modified within your replay window. It does not validate business payload fields such as `organizationId`, event type, wallet account ownership, or whether the event should affect your internal state. + +The helper checks required signature headers, timestamp freshness, key matching, signature format, verification-key shape, supported algorithm/version, and the Ed25519 signature itself. diff --git a/snippets/webhook-site-url-generator.mdx b/snippets/webhook-site-url-generator.mdx new file mode 100644 index 00000000..f90d83fc --- /dev/null +++ b/snippets/webhook-site-url-generator.mdx @@ -0,0 +1,179 @@ +export const WebhookSiteUrlGenerator = () => { + const STORAGE_KEY = "turnkey.webhooks.quickstart.webhookSiteUrl"; + const OUTPUT_ID = "tk-webhook-url-helper-output-value"; + const MESSAGE_ID = "tk-webhook-url-helper-message"; + const GENERATE_ID = "tk-webhook-url-helper-generate"; + const COPY_ID = "tk-webhook-url-helper-copy"; + + const setHelperState = (nextState) => { + const webhookUrl = nextState.webhookUrl || ""; + const status = nextState.status || "idle"; + const message = nextState.message || ""; + + if (typeof document === "undefined") { + return; + } + + const outputElement = document.getElementById(OUTPUT_ID); + const messageElement = document.getElementById(MESSAGE_ID); + const generateButton = document.getElementById(GENERATE_ID); + const copyButton = document.getElementById(COPY_ID); + + if (outputElement) { + outputElement.textContent = webhookUrl + ? `WEBHOOK_URL="${webhookUrl}"` + : 'WEBHOOK_URL="https://webhook.site/..."'; + outputElement.setAttribute("data-webhook-url", webhookUrl); + } + + if (messageElement) { + messageElement.textContent = message; + messageElement.className = `tk-webhook-url-helper-message tk-webhook-url-helper-message-${status}`; + } + + if (generateButton) { + generateButton.textContent = webhookUrl ? "Regenerate" : "Generate URL"; + generateButton.disabled = status === "loading"; + } + + if (copyButton) { + copyButton.disabled = !webhookUrl; + } + }; + + const initialize = (element) => { + if (!element || element.getAttribute("data-initialized") === "true") { + return; + } + + element.setAttribute("data-initialized", "true"); + + try { + const savedUrl = window.localStorage.getItem(STORAGE_KEY); + if (savedUrl) { + setHelperState({ + webhookUrl: savedUrl, + status: "ready", + message: "Saved in this browser. Regenerate when you want a fresh test receiver.", + }); + } + } catch (error) { + setHelperState({ + status: "idle", + message: "Browser storage is unavailable, so generated URLs will not be saved.", + }); + } + }; + + const copyValue = async () => { + const outputElement = document.getElementById(OUTPUT_ID); + const webhookUrl = outputElement?.getAttribute("data-webhook-url"); + + if (!webhookUrl) { + return; + } + + try { + await navigator.clipboard.writeText(`WEBHOOK_URL="${webhookUrl}"`); + setHelperState({ + webhookUrl, + status: "copied", + message: "Copied WEBHOOK_URL to your clipboard.", + }); + } catch (error) { + setHelperState({ + webhookUrl, + status: "error", + message: "Copy failed. Select the WEBHOOK_URL value and copy it manually.", + }); + } + }; + + const generateUrl = async () => { + setHelperState({ + status: "loading", + message: "Creating a temporary Webhook.site URL...", + }); + + try { + const response = await fetch("https://webhook.site/token", { + method: "POST", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Webhook.site returned ${response.status}`); + } + + const token = await response.json(); + + if (!token.uuid) { + throw new Error("Webhook.site did not return a token UUID."); + } + + const nextUrl = `https://webhook.site/${token.uuid}`; + + try { + window.localStorage.setItem(STORAGE_KEY, nextUrl); + setHelperState({ + webhookUrl: nextUrl, + status: "ready", + message: "Generated and saved in this browser.", + }); + } catch (error) { + setHelperState({ + webhookUrl: nextUrl, + status: "ready", + message: "Generated, but browser storage is unavailable. Copy it before refreshing.", + }); + } + } catch (error) { + setHelperState({ + status: "error", + message: + "Could not create a URL from this browser. If this is a CORS error, use the existing URL tab or add a docs-side proxy before publishing this helper.", + }); + } + }; + + return ( +
+ +
+
+
Temporary Webhook.site receiver
+
+ Generate a test URL and reuse it in the Dashboard, SDK, and CLI examples below. +
+
+ +
+ +
+ + WEBHOOK_URL="https://webhook.site/..." + + +
+ +
+
+ ); +}; diff --git a/styles.css b/styles.css index a5cc6b5d..19e49ea5 100644 --- a/styles.css +++ b/styles.css @@ -1796,3 +1796,173 @@ a.nav-tabs-item[href="/welcome"][data-active="true"]::before { margin-inline: 0 !important; contain: none !important; } + +/* ============================================================ + Webhooks quickstart helper + ============================================================ */ + +.tk-webhook-url-helper { + display: flex; + flex-direction: column; + gap: 14px; + margin: 20px 0; + padding: 18px; + border: 1px solid rgba(24, 24, 27, 0.12); + border-radius: 8px; + background: #ffffff; +} + +.dark .tk-webhook-url-helper { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); +} + +.tk-webhook-url-helper-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.tk-webhook-url-helper-title { + font-size: 14px; + font-weight: 600; + color: #18181b; +} + +.dark .tk-webhook-url-helper-title { + color: #ffffff; +} + +.tk-webhook-url-helper-description { + margin-top: 4px; + max-width: 560px; + font-size: 13px; + line-height: 1.5; + color: #52525b; +} + +.dark .tk-webhook-url-helper-description { + color: rgba(255, 255, 255, 0.68); +} + +.tk-webhook-url-helper-output { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid rgba(24, 24, 27, 0.1); + border-radius: 6px; + background: #f8fafc; +} + +.dark .tk-webhook-url-helper-output { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.22); +} + +.tk-webhook-url-helper-output code { + overflow-x: auto; + white-space: nowrap; + font-size: 13px; + color: #27272a; +} + +.dark .tk-webhook-url-helper-output code { + color: #f4f4f5; +} + +.tk-webhook-url-helper-button, +.tk-webhook-url-helper-copy { + flex-shrink: 0; + border: 1px solid rgba(24, 24, 27, 0.12); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + line-height: 1; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + opacity 120ms ease; +} + +.tk-webhook-url-helper-button { + padding: 10px 12px; + background: #18181b; + color: #ffffff; +} + +.tk-webhook-url-helper-copy { + padding: 8px 10px; + background: #ffffff; + color: #18181b; +} + +.dark .tk-webhook-url-helper-button { + border-color: rgba(255, 255, 255, 0.16); + background: #ffffff; + color: #18181b; +} + +.dark .tk-webhook-url-helper-copy { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; +} + +.tk-webhook-url-helper-button:hover:not(:disabled), +.tk-webhook-url-helper-copy:hover:not(:disabled) { + border-color: #4C48FF; +} + +.tk-webhook-url-helper-button:disabled, +.tk-webhook-url-helper-copy:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.tk-webhook-url-helper-message { + font-size: 13px; + line-height: 1.45; + color: #52525b; +} + +.dark .tk-webhook-url-helper-message { + color: rgba(255, 255, 255, 0.68); +} + +.tk-webhook-url-helper-message-error { + color: #b42318; +} + +.dark .tk-webhook-url-helper-message-error { + color: #fda29b; +} + +.tk-webhook-url-helper-message-ready, +.tk-webhook-url-helper-message-copied { + color: #067647; +} + +.dark .tk-webhook-url-helper-message-ready, +.dark .tk-webhook-url-helper-message-copied { + color: #75e0a7; +} + +@media (max-width: 640px) { + .tk-webhook-url-helper-header { + flex-direction: column; + } + + .tk-webhook-url-helper-button, + .tk-webhook-url-helper-copy { + width: 100%; + } + + .tk-webhook-url-helper-output { + grid-template-columns: 1fr; + } +}