Skip to content
Developers

Financial infrastructure for autonomous software.

Agents submit structured intents over HTTPS. VEYRA checks them against owner policies, simulates on Solana mainnet and signs only within on-chain limits. Opaque transactions are never accepted.

Start here

For AI agents

An AI agent can open an account, fund it, configure itself and operate without a browser, a wallet app or a human click. It holds an ed25519 owner key in its own runtime and proves control of it by signing. VEYRA never receives the key: every on-chain step comes back as a transaction prepared and simulated on Solana mainnet, which the agent signs and submits.

ResourceFor
/openapi.jsonOpenAPI 3.1 spec with schemas, units and examples. Load it as tools.
/llms.txtThe whole guide as plain text for a model's context, including TypeScript and Python.
/sdk/veyra.tsSingle-file TypeScript SDK with no dependencies. Download it with curl.
Before you start. The owner key needs SOL on Solana mainnet once (about 0.02 SOL for rent and fees plus anything you deposit). There is no test network: practice with POST /api/v1/intents/preview, which evaluates every policy and quotes fees without creating or signing anything. To keep a person in control, add their wallet to coOwners and set threshold to 2.
autonomous onboarding, curl
sh
# 1. Prove control of the owner key (ed25519). Nothing else is needed: no browser, no wallet app.curl -X POST "$VEYRA_URL/api/v1/auth/challenge" -H "Content-Type: application/json"   -d '{ "address": "<owner public key, base58>" }'# -> { "message": "...", "nonceToken": "..." }   sign the UTF-8 bytes of message, base58 the signaturecurl -X POST "$VEYRA_URL/api/v1/auth/token" -H "Content-Type: application/json"   -d '{ "address": "...", "message": "...", "signature": "...", "nonceToken": "...", "organizationName": "My agent" }'# -> { "token": "vot_..." }   the organization exists now# 2. Create the Squads account. The response is a transaction prepared and simulated on mainnet.curl -X POST "$VEYRA_URL/api/v1/accounts" -H "Authorization: Bearer $VOT" -H "Content-Type: application/json"   -d '{ "name": "Agent Ops", "type": "AGENT", "coOwners": [], "threshold": 1,        "vaults": [{ "name": "Operating Vault", "type": "OPERATING", "description": "" }],        "deposit": { "asset": "SOL", "amount": "0.05" } }'# -> { "id": "ptx_...", "transaction": "<base64>", "simulation": { "ok": true } }# 3. Add the owner signature to the transaction (keep existing signatures) and submit it.curl -X POST "$VEYRA_URL/api/v1/tx/ptx_.../submit" -H "Authorization: Bearer $VOT" -H "Content-Type: application/json"   -d '{ "transaction": "<signed base64>" }'# -> { "signature": "...", "result": { "accountId": "acc_..." } }   confirmed on Solana mainnet# 4. Same pattern for POST /api/v1/agents, deposits, approvals and withdrawals.
autonomous onboarding, TypeScript
ts
// curl -o veyra.ts "$VEYRA_URL/sdk/veyra.ts"   (single file, no dependencies)import { Veyra, signInWithKey, type OwnerSigner } from "./veyra";import { ed25519 } from "@noble/curves/ed25519.js";import bs58 from "bs58";const secret = ed25519.utils.randomSecretKey();           // persist securely, then fund the address with SOLconst owner: OwnerSigner = { publicKey: bs58.encode(ed25519.getPublicKey(secret)), signMessage: (m) => ed25519.sign(m, secret) };const { token } = await signInWithKey(process.env.VEYRA_URL!, owner, { organizationName: "My agent" });const veyra = new Veyra({ apiKey: token, baseUrl: process.env.VEYRA_URL! });const { result: { accountId } } = await veyra.owner.createAccount({ name: "Agent Ops", deposit: { asset: "SOL", amount: "0.05" } }, owner);const ctx: any = await veyra.context();const payee = await veyra.owner.createCounterparty({ name: "Vendor", type: "BUSINESS", address: "<base58>" });const { result: { agentId } } = await veyra.owner.createAgent(  { accountId, vaultId: ctx.vaults[0].id, name: "Atlas", kind: "PAYMENT", limits: [{ asset: "SOL", amount: "0.02" }], period: "DAY", counterpartyIds: [payee.id], gasSol: "0.01" },  owner,);const { execution } = await veyra.intents.submit({ agentId, action: "PAYMENT", asset: "SOL", amount: "0.003", counterpartyId: payee.id });console.log(await veyra.executions.wait(execution.id));

Already have credentials? GET /api/v1/context returns your agent id, vault ids, allowed actions, remaining on-chain allowance and the counterparties you can pay, in one call.

Reference

Units

FieldUnitExample
Request amount (intents, payments, deposits, limits)Decimal string in token units"12.5" USDC
Balances, intent.amount, limits, simulation deltasInteger string in the smallest unit (USDC 6 decimals, SOL 9)"12500000" = 12.5 USDC
amountUsd, policy amounts, allowancesInteger USD cents250000 = $2,500.00
Fees (feeQuote, preview fee)Integer string micro-USD (1e-6)"1879000" = $1.879
maxSlippageBpsBasis points50 = 0.50%
01

Quickstart

The base URL is the origin of your deployment. Every request carries a bearer credential. Both kinds are created in the developer dashboard, shown once and stored by VEYRA only as SHA-256 hashes.

CredentialFormatScope
API keyBearer vk_live_...Organization. Read, submit intents and payments, pause agents, manage counterparties and webhooks. Cannot vote on approvals or change policies.
Agent sessionBearer vas_...One agent: its vaults, allowed actions, a USD spending allowance and an expiry. Can submit intents, read its own state and request changes.
1Set your environment
shell
sh
# The base URL is the origin of your VEYRA deploymentexport VEYRA_URL="https://your-veyra-deployment"# API key (organization scope), created in /developers/dashboardexport VEYRA_API_KEY="vk_live_..."# Agent session token (one agent, its vaults and allowed actions)export VEYRA_AGENT_TOKEN="vas_..."# Find your agentId, vaultId and counterparty ids with one callcurl "$VEYRA_URL/api/v1/context" -H "Authorization: Bearer $VEYRA_AGENT_TOKEN"
2Preview the decision and fee
preview
sh
curl -X POST "$VEYRA_URL/api/v1/intents/preview" \  -H "Authorization: Bearer $VEYRA_AGENT_TOKEN" \  -H "Content-Type: application/json" \  -d '{    "agentId": "agt_...",    "action": "SWAP",    "asset": "USDC",    "outputAsset": "SOL",    "amount": "250",    "maxSlippageBps": 50  }'
3Submit the intent
submit
sh
curl -X POST "$VEYRA_URL/api/v1/intents" \  -H "Authorization: Bearer $VEYRA_AGENT_TOKEN" \  -H "Content-Type: application/json" \  -H "idempotency-key: rebalance-2026-10-05-001" \  -d '{    "agentId": "agt_...",    "action": "SWAP",    "asset": "USDC",    "outputAsset": "SOL",    "amount": "250",    "maxSlippageBps": 50,    "justification": "Rebalance to target SOL weight"  }'# 202 Accepted (200 with "replayed": true for a repeated idempotency key)# { "execution": { "id": "exe_...", "state": "CREATED", ... }, "replayed": false }
4Follow the execution

Poll until the state is terminal or AWAITING_APPROVAL, or subscribe to webhooks.

poll
sh
curl "$VEYRA_URL/api/v1/executions/exe_..." \  -H "Authorization: Bearer $VEYRA_AGENT_TOKEN"
02

Agent Accounts

An account is a Squads v4 multisig on Solana mainnet. Owners are wallets with full permissions; the threshold sets how many must approve. Each agent receives a dedicated executor key, derived server-side and never stored, added to the multisig with Initiate and Execute permissions only. It cannot vote.

Creating an agent, changing its spending limits and revoking it are Squads transactions signed by owner wallets, so they happen in the app. Through the API you read agents and pause or resume them.

GET /api/v1/agents/:id
sh
curl "$VEYRA_URL/api/v1/agents/agt_..." \  -H "Authorization: Bearer $VEYRA_AGENT_TOKEN"# {#   "agent": { "id": "agt_...", "status": "ACTIVE", "chainStatus": "ACTIVE",#              "executor": "<executor public key>", "primaryVaultId": "vlt_...", ... },#   "limits": [ { "asset": "USDC", "amount": "...", "period": "DAY",#                 "destinations": ["..."], "remaining": "...", ... } ],#   "wallet": { "balances": [ ... ], "totalUsd": 0 },#   "gasLamports": 0# }
PATCH /api/v1/agents/:id
sh
# Pause or resume an agent (API key)curl -X PATCH "$VEYRA_URL/api/v1/agents/agt_..." \  -H "Authorization: Bearer $VEYRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "status": "PAUSED" }'
A paused agent keeps its on-chain membership, but VEYRA signs nothing for it until it is resumed. Revoked agents cannot be resumed.
03

Vaults

A vault is a Squads vault PDA of an account. Funds stay in the vault under owner control. Agents draw from it only through their on-chain spending limits (amount per period, per asset, to allow-listed destinations) or through owner-approved proposals.

GET /api/v1/vaults
sh
curl "$VEYRA_URL/api/v1/vaults" \  -H "Authorization: Bearer $VEYRA_API_KEY"# [ { "id": "vlt_...", "name": "...", "type": "PAYMENT", "address": "<vault PDA>",#     "status": "ACTIVE", "balances": [ { "asset": "USDC", "amount": "<base units>",#     "decimals": 6, "usdValue": 0 } ], "totalUsd": 0, ... } ]

Token amounts are base-unit strings. USD values are integer cents. Vaults are created, funded and frozen by owners in the app. A frozen vault blocks every intent that draws from it.

04

Policies

Policies are deterministic rules evaluated before anything is signed. There is no model in the loop: the same intent and state always produce the same decision. USD amounts are cents. Empty scopes apply to every agent and vault in the organization.

Rule kindParameters
DAILY_LIMITamountUsd
MAX_TRANSACTIONamountUsd
APPROVAL_THRESHOLDamountUsd
ALLOWED_ASSETS / BLOCKED_ASSETSassets
ALLOWED_PROTOCOLS / BLOCKED_PROTOCOLSprotocols
ALLOWED_COUNTERPARTIES / BLOCKED_COUNTERPARTIEScounterpartyIds
ALLOWED_ACTIONSactions
TIME_WINDOWstartHourUtc, endHourUtc, days
EXECUTION_FREQUENCYmaxPerHour
MAX_SLIPPAGEbps
COUNTERPARTY_DAILY_LIMITasset, amountUsd

Every evaluation returns a decision object. It is attached to the execution as policy and returned by the preview endpoint as evaluation.

PolicyEvaluation
json
{  "decision": "REQUIRE_APPROVAL",  "triggeredPolicies": [    {      "policyId": "pol_...",      "reason": "TRANSACTION_ABOVE_THRESHOLD",      "outcome": "REQUIRE_APPROVAL",      "message": "..."    }  ],  "approvalRequirement": {    "rule": { "kind": "THRESHOLD", "required": 1, "of": ["OWNER"] },    "approvalsRequired": 1  },  "evaluatedPolicyIds": ["pol_..."],  "evaluatedAt": "<ISO 8601>"}
  • ALLOW executes through the agent's spending limit.
  • REQUIRE_APPROVAL waits for owners. For vault funds the agent proposes a Squads transaction that owners approve on-chain.
  • BLOCK stops the execution. Nothing is signed.

Policies are changed by owners. Agents propose changes through change requests (see Approvals).

05

Execution

ActionWhat it doesRequires
SWAPSwap from the agent's working wallet, routed through Jupiter.outputAsset, maxSlippageBps
PAYMENTPay a counterparty from a vault.counterpartyId
TRANSFERMove value from a vault to a counterparty.counterpartyId
ALLOCATEMove capital from a vault to the agent's working wallet.No extra field
RETURNReturn capital from the working wallet to the vault.No extra field

Body fields: agentId, vaultId (optional, defaults to the agent's primary vault), action, asset, amount (decimal token amount as a string), and optionally outputAsset, counterpartyId, maxSlippageBps (0 to 5000), memo, justification.

POST /api/v1/intents/preview
json
{  "amountUsd": 25000,             // cents, priced server-side  "evaluation": { "decision": "ALLOW", "triggeredPolicies": [], ... },  "fee": {                        // USD micro-units (1e-6), as strings    "networkFee": "...",    "executionFee": "...",    "providerFee": "...",    "totalFee": "..."  },  "route": "JUPITER",             // SPENDING_LIMIT | SQUADS_PROPOSAL | AGENT_WALLET | JUPITER  "estimatedTime": "About 15 seconds"}

Execution states

CREATEDIntent recorded with its idempotency key.
VALIDATINGAgent, vault, session scope and balance checks.
POLICY_CHECKDeterministic policy evaluation.
AWAITING_APPROVALPolicy requires owners to approve. Execution resumes after the decision.
APPROVEDAllowed by policy, or approved by owners.
SIMULATINGTransaction built and simulated against mainnet, then compared with the intent.
READYSimulation matched the intent. Ready to sign.
SUBMITTEDSigned and sent to Solana.
CONFIRMEDConfirmed on-chain.
SETTLEDFinal. Settlement recorded with signature, slot and fee.
BLOCKEDFinal. Policy, simulation mismatch or rejection. Nothing was signed.
FAILEDFinal. Check error and fundsMayHaveMoved before retrying.
CANCELLEDFinal. Cancelled before submission.
Idempotency: send an idempotency-key header (8 to 128 characters). The same key with the same body returns the original execution with replayed: true. The same key with a different body returns 409 IDEMPOTENCY_CONFLICT. Retry network failures with the same key.
06

Payments

A payment is a PAYMENT intent from a vault to a registered counterparty, with optional reference, invoice and memo. Payable assets: USDC, USDT, PYUSD, SOL. The destination must be on the agent's on-chain allow-list.

POST /api/v1/payments
sh
curl -X POST "$VEYRA_URL/api/v1/payments" \  -H "Authorization: Bearer $VEYRA_API_KEY" \  -H "Content-Type: application/json" \  -H "idempotency-key: inv-0042-payment" \  -d '{    "agentId": "agt_...",    "counterpartyId": "cp_...",    "asset": "USDC",    "amount": "980.00",    "reference": "PO-118",    "invoice": "INV-0042",    "memo": "October hosting"  }'# { "paymentId": "pay_...", "execution": { "id": "exe_...", "state": "CREATED", ... } }
/api/v1/payments requires an API key. Agent session tokens pay by submitting a PAYMENT intent to /api/v1/intents.
07

Approvals

Approvals are decided by organization members signing with their wallets (owners by default), never by API keys or agents. For vault funds, approval is an on-chain Squads proposal vote. For swaps from an agent's working wallet and for change requests, owners sign an off-chain approval message with their wallet.

GET /api/v1/approvals
sh
curl "$VEYRA_URL/api/v1/approvals?status=PENDING" \  -H "Authorization: Bearer $VEYRA_API_KEY"

Agents ask for changes instead of working around limits. Types: POLICY_CHANGE, COUNTERPARTY, PROTOCOL, LIMIT_INCREASE, AGENT_PERMISSION.

POST /api/v1/agents/:id/requests
sh
curl -X POST "$VEYRA_URL/api/v1/agents/agt_.../requests" \  -H "Authorization: Bearer $VEYRA_AGENT_TOKEN" \  -H "Content-Type: application/json" \  -d '{    "type": "LIMIT_INCREASE",    "title": "Raise daily limit",    "requestedChange": "Daily limit to 5,000 USDC",    "currentRule": "Daily limit 2,000 USDC",    "justification": "Settlement volume doubled this week."  }'# Returns the created approval request (status PENDING) for owners to decide.
08

SDK

The SDK is one dependency-free TypeScript file for Node 18+, Bun, Deno, browsers and edge runtimes. Download it from /sdk/veyra.ts (curl -o veyra.ts $VEYRA_URL/sdk/veyra.ts). It is not on npm, so there is nothing to install.

agent.ts
ts
// curl -o veyra.ts "$VEYRA_URL/sdk/veyra.ts"import { Veyra, VeyraError } from "./veyra";const veyra = new Veyra({  apiKey: process.env.VEYRA_AGENT_TOKEN!, // vk_live_... or vas_...  baseUrl: process.env.VEYRA_URL!,});const agent = veyra.agent("agt_...");// Check the decision and fee first. Nothing is created or signed.const preview = await veyra.intents.preview({  agentId: agent.id, action: "PAYMENT", asset: "USDC", amount: "120", counterpartyId: "cp_...",});if (preview.evaluation.decision !== "BLOCK") {  const { execution } = await agent.pay(    { counterpartyId: "cp_...", asset: "USDC", amount: "120", memo: "INV-0042" },    { idempotencyKey: "inv-0042" },  );  const final = await veyra.executions.wait(execution.id, { timeoutMs: 90_000 });  // SETTLED | BLOCKED | FAILED | CANCELLED | AWAITING_APPROVAL}try {  await agent.swap({ from: "USDC", to: "SOL", amount: "250", maxSlippageBps: 50 });} catch (e) {  if (e instanceof VeyraError) console.error(e.status, e.code, e.message);}
surface
ts
new Veyra({ apiKey, baseUrl })veyra.intents.submit(body, { idempotencyKey? })veyra.intents.preview(body)veyra.executions.get(id)veyra.executions.list({ agentId?, state?, limit? })veyra.executions.wait(id, { timeoutMs?, intervalMs? })veyra.payments.create(body, { idempotencyKey? })   // API keyveyra.approvals.list(status?)veyra.agents.list()veyra.agents.get(id)veyra.agents.requestChange(agentId, body)veyra.webhooks.verify(rawBody, header, secret)      // async, Web Cryptoveyra.context()                                     // ids, scope, limits, unitssignInWithKey(baseUrl, ownerSigner)                 // -> { token: "vot_..." }veyra.owner.createAccount(body, signer)             // prepare, sign, submit, confirmveyra.owner.deposit(vaultId, body, signer)veyra.owner.createCounterparty(body)veyra.owner.createAgent(body, signer)veyra.owner.issueSession(body)                      // -> { token: "vas_..." }veyra.owner.approve(approvalId, signer)veyra.owner.withdraw(vaultId, body, signer)veyra.owner.run(path, body, signer)                 // any endpoint returning a prepared transactionsignPreparedTransaction(base64, signer)             // adds one signature, keeps the restconst agent = veyra.agent(agentId)agent.swap({ from, to, amount, maxSlippageBps })agent.pay({ counterpartyId, asset, amount, memo })agent.allocate({ asset, amount })agent.returnCapital({ asset, amount })agent.request({ type, title, requestedChange, justification })
A BLOCKED execution is not an error. The request succeeded and the policy engine decided not to sign. Read execution.policy.triggeredPolicies.
09

API

JSON over HTTPS. Errors return { "error": { "code", "message" } }. Agent session tokens only see their own agent's executions, approvals and detail.

MethodPathDescriptionCredential
POST/api/v1/auth/challengeStart sign-in for an owner key. Public.None
POST/api/v1/auth/tokenSigned challenge for an owner token (vot_...). Public.None
GET/api/v1/contextIds, scope, on-chain limits, units and rate limits for this credential.Any
POST/api/v1/tx/:id/submitSubmit an owner-signed prepared transaction.Owner
POST/api/v1/accountsCreate a Squads account. Returns a prepared transaction.Owner
POST/api/v1/agentsCreate and authorize an agent. Returns a prepared transaction.Owner
POST/api/v1/vaults/:id/depositDeposit from the owner wallet. Prepared transaction.Owner
POST/api/v1/vaults/:id/withdrawOwner withdrawal, step 1 of 2. Prepared transaction.Owner
POST/api/v1/approvals/:id/onchain-voteOwner vote on a Squads proposal. Prepared transaction.Owner
POST/api/v1/developers/sessionsIssue an agent session token (vas_...).Owner
POST/api/v1/intentsSubmit an intent. Header idempotency-key.Key, Agent
POST/api/v1/intents/previewPolicy decision, fee quote and route. Read-only.Key, Agent
GET/api/v1/executionsList executions. ?agentId= &state= &limit=Key, Agent
GET/api/v1/executions/:idOne execution with steps, policy, simulation, signature.Key, Agent
POST/api/v1/paymentsCreate a payment to a counterparty.Key
GET/api/v1/paymentsList payments.Key
GET/api/v1/approvalsList approvals. ?status=PENDINGKey, Agent
POST/api/v1/agents/:id/requestsAgent change request for owners to decide.Key, Agent
GET/api/v1/agentsList agents.Key, Agent
GET/api/v1/agents/:idAgent, on-chain limits, working wallet.Key, Agent
PATCH/api/v1/agents/:idPause or resume: { status }.Key
GET/api/v1/vaultsVaults with live balances.Key
GET/api/v1/policiesPolicies.Key
GET/api/v1/counterpartiesCounterparties.Key
POST/api/v1/webhooksCreate an endpoint. Returns the secret once.Key
PATCH/api/v1/webhooks/:idEnable or disable: { enabled }.Key
GET/api/v1/usageUsage and fees, last 30 days.Key

Errors

StatusCodeMeaning
400VALIDATION_ERRORBody failed validation. The message lists each field.
401UNAUTHORIZEDMissing, invalid, revoked or expired credential.
403FORBIDDENCredential lacks permission, or the session scope does not allow the intent.
404NOT_FOUNDResource does not exist in your organization.
409IDEMPOTENCY_CONFLICTIdempotency key reused with a different body.
429RATE_LIMITEDToo many requests. Retry-After header and error.retryAfterSeconds give the delay.
409TX_TAMPEREDThe submitted transaction differs from the prepared one. Nothing was sent.
503PRICE_UNAVAILABLEUSD pricing unavailable. Nothing was created.

Rate limits

ScopeLimitNotes
Owner token / signed-in user120 per minutePer member.
API key600 per minutePer key.
Agent sessionConfigured per tokenDefault 30 per minute, set when the token is issued.
Organization2,000 per minuteAcross all credentials.
Loop protection12 actions per minute, 3 identical intents per 5 minutes, 500 per dayPer agent. Tripping it blocks the intent and pauses the agent until an owner resumes it.

429 responses carry a Retry-After header and error.retryAfterSeconds. Full machine-readable description: /openapi.json.

10

Webhooks

Endpoints must be public HTTPS URLs. Each delivery is a POST with a JSON body, signed with your endpoint secret. Failed deliveries are retried twice (after 1 and 4 seconds) with an 8 second timeout per attempt. Delivery results appear in the developer dashboard.

POST /api/v1/webhooks
sh
curl -X POST "$VEYRA_URL/api/v1/webhooks" \  -H "Authorization: Bearer $VEYRA_API_KEY" \  -H "Content-Type: application/json" \  -d '{    "url": "https://example.com/veyra/webhooks",    "events": ["agent.execution.settled", "agent.execution.blocked", "approval.required"]  }'# { "id": "whk_...", "secret": "whsec_..." }   The secret is shown once.
agent.execution.requestedAn intent was submitted.
agent.execution.approvedOwners approved an execution.
agent.execution.blockedPolicy or simulation blocked an execution.
agent.execution.settledAn execution settled on Solana.
payment.createdA payment was created.
payment.settledA payment settled.
approval.requiredAn approval request needs owners.
approval.completedAn approval request was decided.
policy.changedA policy was created or changed.
vault.fundedA vault received a deposit.
vault.frozenA vault was frozen.

Signature

Header veyra-signature: t=<unix>,v1=<hex hmac_sha256(secret, "<t>.<body>")>. Compute the HMAC over the timestamp, a period and the raw body, compare in constant time and reject timestamps more than 300 seconds from now.

delivery
http
POST /veyra/webhookscontent-type: application/jsonuser-agent: VEYRA-Webhooks/1veyra-signature: t=1791200000,v1=5f2c...e81a{ "id": "whd_...", "type": "agent.execution.settled", "createdAt": "<ISO 8601>", "data": { ... } }
app/veyra/webhooks/route.ts
ts
import { createHmac, timingSafeEqual } from "node:crypto";const TOLERANCE_S = 300;export function verifyVeyraSignature(rawBody: string, header: string | null, secret: string): boolean {  if (!header) return false;  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2) as [string, string]));  const t = Number(parts.t);  if (!Number.isInteger(t) || !parts.v1) return false;  // Reject old or future timestamps (replay protection)  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_S) return false;  const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "hex");  const received = Buffer.from(parts.v1, "hex");  return expected.length === received.length && timingSafeEqual(expected, received);}// Next.js route handlerexport async function POST(req: Request) {  const rawBody = await req.text(); // verify the exact bytes you received  if (!verifyVeyraSignature(rawBody, req.headers.get("veyra-signature"), process.env.VEYRA_WEBHOOK_SECRET!)) {    return new Response("invalid signature", { status: 400 });  }  const event = JSON.parse(rawBody) as { id: string; type: string; data: Record<string, unknown> };  // Deduplicate on event.id: deliveries can be retried.  return new Response("ok");}

With the SDK: await veyra.webhooks.verify(rawBody, header, secret) returns { ok: true } or { ok: false, reason }.