Financial infrastructure for autonomous software.
Agents submit structured intents over HTTPS. ELYX checks them against owner policies, simulates on Elysium mainnet and signs only within on-chain limits. Opaque transactions are never accepted.
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 a secp256k1 owner key in its own runtime and proves control of it by signing (EIP-4361). ELYX never receives the key: every on-chain step comes back as an EIP-1559 transaction prepared and simulated on Elysium mainnet, which the agent decodes, signs and submits.
| Resource | For |
|---|---|
| /openapi.json | OpenAPI 3.1 spec with schemas, units and examples. Load it as tools. |
| /llms.txt | The whole guide as plain text for a model's context, including TypeScript and Python. |
| /sdk/elyx.ts | Single-file TypeScript SDK with no dependencies. Download it with curl. |
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.# 1. Prove control of the owner key (secp256k1, EIP-4361). Nothing else is needed: no browser, no wallet app.curl -X POST "$ELYX_URL/api/v1/auth/challenge" -H "Content-Type: application/json" -d '{ "address": "0x<owner address>" }'# -> { "message": "...", "nonceToken": "..." } personal_sign the message (EIP-191), 0x signaturecurl -X POST "$ELYX_URL/api/v1/auth/token" -H "Content-Type: application/json" -d '{ "address": "0x...", "message": "...", "signature": "0x...", "nonceToken": "...", "organizationName": "My agent" }'# -> { "token": "eot_..." } the organization exists now# 2. Create the Safe account. The response is an EIP-1559 transaction prepared and simulated on Elysium.curl -X POST "$ELYX_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": "" }] }'# -> { "id": "ptx_...", "transaction": { "chainId": 99901, "to": "0x...", "data": "0x...", "value": "0", ... }, "simulation": { "ok": true } }# 3. Sign the transaction with the owner key and submit the raw signed bytes.curl -X POST "$ELYX_URL/api/v1/tx/ptx_.../submit" -H "Authorization: Bearer $VOT" -H "Content-Type: application/json" -d '{ "transaction": "0x02..." }'# -> { "signature": "0x<tx hash>", "result": { "accountId": "acc_...", "vaultId": "vlt_...", "vaultAddress": "0x..." } } confirmed on Elysium# 4. Same pattern for deposits, POST /api/v1/agents, top-ups, withdrawals and executing approved Safe transactions.// curl -o elyx.ts "$ELYX_URL/sdk/elyx.ts" (single file, no dependencies; viem only for the key)import { Elyx, signInWithKey, viemSigner } from "./elyx";import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";const owner = viemSigner(privateKeyToAccount(generatePrivateKey())); // persist the key securely, then fund the address with HYPEconst { token } = await signInWithKey(process.env.ELYX_URL!, owner, { organizationName: "My agent" });const elyx = new Elyx({ apiKey: token, baseUrl: process.env.ELYX_URL!, moduleAddress: "0x<ELYX module>" });const { result: { accountId, vaultId, vaultAddress } } = await elyx.owner.createAccount({ name: "Agent Ops" }, owner);await elyx.owner.deposit(vaultId, { asset: "HYPE", amount: "0.05" }, owner, { vaultAddress });const payee = await elyx.owner.createCounterparty({ name: "Vendor", type: "BUSINESS", address: "0x..." });const onboarding = await elyx.owner.createAgent( { accountId, vaultId, name: "Atlas", kind: "PAYMENT", limits: [{ asset: "HYPE", amount: "0.02" }], period: "DAY", counterpartyIds: [payee.id], gasHype: "0.01" }, owner, { vaultAddress },);if (onboarding.status !== "CONFIRMED") throw new Error("co-owners must sign first");const { agentId } = onboarding.result;const { execution } = await elyx.intents.submit({ agentId, action: "PAYMENT", asset: "HYPE", amount: "0.003", counterpartyId: payee.id });console.log(await elyx.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.
Units
| Field | Unit | Example |
|---|---|---|
Request amount (intents, payments, deposits, limits) | Decimal string in token units | "12.5" USDC |
Balances, intent.amount, limits, simulation deltas | Integer string in the smallest unit (USDC 6 decimals, HYPE 18) | "12500000" = 12.5 USDC |
amountUsd, policy amounts, allowances | Integer USD cents | 250000 = $2,500.00 |
Fees (feeQuote, preview fee) | Integer string micro-USD (1e-6) | "1879000" = $1.879 |
maxSlippageBps | Basis points | 50 = 0.50% |
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 ELYX only as SHA-256 hashes.
| Credential | Format | Scope |
|---|---|---|
| API key | Bearer ek_live_... | Organization. Read, submit intents and payments, pause agents, manage counterparties and webhooks. Cannot vote on approvals or change policies. |
| Agent session | Bearer eas_... | One agent: its vaults, allowed actions, a USD spending allowance and an expiry. Can submit intents, read its own state and request changes. |
# The base URL is the origin of your ELYX deploymentexport ELYX_URL="https://your-elyx-deployment"# API key (organization scope), created in /developers/dashboardexport ELYX_API_KEY="ek_live_..."# Agent session token (one agent, its vaults and allowed actions)export ELYX_AGENT_TOKEN="eas_..."# Find your agentId, vaultId and counterparty ids with one callcurl "$ELYX_URL/api/v1/context" -H "Authorization: Bearer $ELYX_AGENT_TOKEN"curl -X POST "$ELYX_URL/api/v1/intents/preview" \ -H "Authorization: Bearer $ELYX_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "agentId": "agt_...", "action": "SWAP", "asset": "USDC", "outputAsset": "HYPE", "amount": "250", "maxSlippageBps": 50 }'curl -X POST "$ELYX_URL/api/v1/intents" \ -H "Authorization: Bearer $ELYX_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "idempotency-key: rebalance-2026-10-05-001" \ -d '{ "agentId": "agt_...", "action": "SWAP", "asset": "USDC", "outputAsset": "HYPE", "amount": "250", "maxSlippageBps": 50, "justification": "Rebalance to target HYPE weight" }'# 202 Accepted (200 with "replayed": true for a repeated idempotency key)# { "execution": { "id": "exe_...", "state": "CREATED", ... }, "replayed": false }Poll until the state is terminal or AWAITING_APPROVAL, or subscribe to webhooks.
curl "$ELYX_URL/api/v1/executions/exe_..." \ -H "Authorization: Bearer $ELYX_AGENT_TOKEN"Agent Accounts
An account is a Safe smart account on Elysium mainnet. Owners are wallets that sign its transactions; the threshold sets how many must sign. Each agent receives a dedicated executor key, derived server-side and never stored, registered as a delegate of the ELYX spending limit module on its vault. It is never a Safe owner.
Creating an agent, changing its spending limits and revoking it are Safe transactions signed by owner wallets: in the app, or from an agent runtime holding an owner key through the SDK. Through the API you read agents and pause or resume them.
curl "$ELYX_URL/api/v1/agents/agt_..." \ -H "Authorization: Bearer $ELYX_AGENT_TOKEN"# {# "agent": { "id": "agt_...", "status": "ACTIVE", "chainStatus": "ACTIVE",# "executor": "0x<executor address>", "primaryVaultId": "vlt_...", ... },# "limits": [ { "asset": "USDC", "amount": "...", "period": "DAY",# "destinations": ["..."], "remaining": "...", ... } ],# "wallet": { "balances": [ ... ], "totalUsd": 0 },# "gasWei": "0"# }# Pause or resume an agent (API key)curl -X PATCH "$ELYX_URL/api/v1/agents/agt_..." \ -H "Authorization: Bearer $ELYX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "PAUSED" }'Vaults
A vault is a Safe owned by the account's owners (the first vault is the account itself). 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-signed Safe transactions.
curl "$ELYX_URL/api/v1/vaults" \ -H "Authorization: Bearer $ELYX_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.
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 kind | Parameters |
|---|---|
DAILY_LIMIT | amountUsd |
MAX_TRANSACTION | amountUsd |
APPROVAL_THRESHOLD | amountUsd |
ALLOWED_ASSETS / BLOCKED_ASSETS | assets |
ALLOWED_PROTOCOLS / BLOCKED_PROTOCOLS | protocols |
ALLOWED_COUNTERPARTIES / BLOCKED_COUNTERPARTIES | counterpartyIds |
ALLOWED_ACTIONS | actions |
TIME_WINDOW | startHourUtc, endHourUtc, days |
EXECUTION_FREQUENCY | maxPerHour |
MAX_SLIPPAGE | bps |
COUNTERPARTY_DAILY_LIMIT | asset, amountUsd |
Every evaluation returns a decision object. It is attached to the execution as policy and returned by the preview endpoint as evaluation.
{ "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>"}ALLOWexecutes through the agent's spending limit.REQUIRE_APPROVALwaits for owners. For vault funds the agent drafts a Safe transaction that owners sign (EIP-712, no gas) and ELYX executes.BLOCKstops the execution. Nothing is signed.
Policies are changed by owners. Agents propose changes through change requests (see Approvals).
Execution
| Action | What it does | Requires |
|---|---|---|
SWAP | Swap from the agent's working wallet, routed through KyberSwap. | outputAsset, maxSlippageBps |
PAYMENT | Pay a counterparty from a vault. | counterpartyId |
TRANSFER | Move value from a vault to a counterparty. | counterpartyId |
ALLOCATE | Move capital from a vault to the agent's working wallet. | No extra field |
RETURN | Return 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.
{ "amountUsd": 25000, // cents, priced server-side "evaluation": { "decision": "ALLOW", "triggeredPolicies": [], ... }, "fee": { // USD micro-units (1e-6), as strings "networkFee": "...", "executionFee": "...", "providerFee": "...", "totalFee": "..." }, "route": "KYBER", // SPENDING_LIMIT | SAFE_TX | AGENT_WALLET | KYBER "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 Elysium.CONFIRMEDConfirmed on-chain.SETTLEDFinal. Settlement recorded with transaction hash, block and fee.BLOCKEDFinal. Policy, simulation mismatch or rejection. Nothing was signed.FAILEDFinal. Check error and fundsMayHaveMoved before retrying.CANCELLEDFinal. Cancelled before submission.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.Payments
A payment is a PAYMENT intent from a vault to a registered counterparty, with optional reference, invoice and memo. Payable assets: USDC, HYPE. The destination must be on the agent's on-chain allow-list.
curl -X POST "$ELYX_URL/api/v1/payments" \ -H "Authorization: Bearer $ELYX_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.Approvals
Approvals are decided by organization members signing with their wallets (owners by default), never by API keys or agents. For vault funds, owners sign the Safe transaction itself (EIP-712 typed data, no gas). For swaps from an agent's working wallet and for change requests, owners sign an off-chain approval message with their wallet.
curl "$ELYX_URL/api/v1/approvals?status=PENDING" \ -H "Authorization: Bearer $ELYX_API_KEY"Agents ask for changes instead of working around limits. Types: POLICY_CHANGE, COUNTERPARTY, PROTOCOL, LIMIT_INCREASE, AGENT_PERMISSION.
curl -X POST "$ELYX_URL/api/v1/agents/agt_.../requests" \ -H "Authorization: Bearer $ELYX_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.SDK
The SDK is one dependency-free TypeScript file for Node 18+, Bun, Deno, browsers and edge runtimes. Download it from /sdk/elyx.ts (curl -o elyx.ts $ELYX_URL/sdk/elyx.ts). It is not on npm, so there is nothing to install.
// curl -o elyx.ts "$ELYX_URL/sdk/elyx.ts"import { Elyx, ElyxError } from "./elyx";const elyx = new Elyx({ apiKey: process.env.ELYX_AGENT_TOKEN!, // ek_live_... or eas_... baseUrl: process.env.ELYX_URL!,});const agent = elyx.agent("agt_...");// Check the decision and fee first. Nothing is created or signed.const preview = await elyx.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 elyx.executions.wait(execution.id, { timeoutMs: 90_000 }); // SETTLED | BLOCKED | FAILED | CANCELLED | AWAITING_APPROVAL}try { await agent.swap({ from: "USDC", to: "HYPE", amount: "250", maxSlippageBps: 50 });} catch (e) { if (e instanceof ElyxError) console.error(e.status, e.code, e.message);}new Elyx({ apiKey, baseUrl })elyx.intents.submit(body, { idempotencyKey? })elyx.intents.preview(body)elyx.executions.get(id)elyx.executions.list({ agentId?, state?, limit? })elyx.executions.wait(id, { timeoutMs?, intervalMs? })elyx.payments.create(body, { idempotencyKey? }) // API keyelyx.approvals.list(status?)elyx.agents.list()elyx.agents.get(id)elyx.agents.requestChange(agentId, body)elyx.webhooks.verify(rawBody, header, secret) // async, Web Cryptoelyx.context() // ids, scope, limits, unitssignInWithKey(baseUrl, ownerSigner) // -> { token: "eot_..." }elyx.owner.createAccount(body, signer) // prepare, sign, submit, confirmelyx.owner.deposit(vaultId, body, signer)elyx.owner.createCounterparty(body)elyx.owner.createAgent(body, signer)elyx.owner.issueSession(body) // -> { token: "eas_..." }elyx.owner.approve(approvalId, signer)elyx.owner.withdraw(vaultId, body, signer)elyx.owner.run(path, body, signer) // any endpoint returning a prepared transactionsignPreparedTransaction(base64, signer) // adds one signature, keeps the restconst agent = elyx.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 })API
JSON over HTTPS. Errors return { "error": { "code", "message" } }. Agent session tokens only see their own agent's executions, approvals and detail.
| Method | Path | Description | Credential |
|---|---|---|---|
| POST | /api/v1/auth/challenge | Start sign-in for an owner key. Public. | None |
| POST | /api/v1/auth/token | Signed challenge for an owner token (eot_...). Public. | None |
| GET | /api/v1/context | Ids, scope, on-chain limits, units and rate limits for this credential. | Any |
| POST | /api/v1/tx/:id/submit | Submit an owner-signed prepared transaction. | Owner |
| POST | /api/v1/accounts | Create a Safe account. Returns a prepared transaction. | Owner |
| POST | /api/v1/agents | Create and authorize an agent. Returns a prepared transaction. | Owner |
| POST | /api/v1/vaults/:id/deposit | Deposit from the owner wallet. Prepared transaction. | Owner |
| POST | /api/v1/vaults/:id/withdraw | Owner withdrawal, step 1 of 2. Prepared transaction. | Owner |
| GET | /api/v1/approvals/:id/safe-typed-data | EIP-712 typed data an owner signs for a Safe transaction (no gas). | Owner |
| POST | /api/v1/approvals/:id/safe-vote | Record an owner's EIP-712 signature or signed rejection. | Owner |
| POST | /api/v1/approvals/:id/execute | Owner executes an approved Safe transaction. Prepared transaction. | Owner |
| POST | /api/v1/developers/sessions | Issue an agent session token (eas_...). | Owner |
| POST | /api/v1/intents | Submit an intent. Header idempotency-key. | Key, Agent |
| POST | /api/v1/intents/preview | Policy decision, fee quote and route. Read-only. | Key, Agent |
| GET | /api/v1/executions | List executions. ?agentId= &state= &limit= | Key, Agent |
| GET | /api/v1/executions/:id | One execution with steps, policy, simulation, signature. | Key, Agent |
| POST | /api/v1/payments | Create a payment to a counterparty. | Key |
| GET | /api/v1/payments | List payments. | Key |
| GET | /api/v1/approvals | List approvals. ?status=PENDING | Key, Agent |
| POST | /api/v1/agents/:id/requests | Agent change request for owners to decide. | Key, Agent |
| GET | /api/v1/agents | List agents. | Key, Agent |
| GET | /api/v1/agents/:id | Agent, on-chain limits, working wallet. | Key, Agent |
| PATCH | /api/v1/agents/:id | Pause or resume: { status }. | Key |
| GET | /api/v1/vaults | Vaults with live balances. | Key |
| GET | /api/v1/policies | Policies. | Key |
| GET | /api/v1/counterparties | Counterparties. | Key |
| POST | /api/v1/webhooks | Create an endpoint. Returns the secret once. | Key |
| PATCH | /api/v1/webhooks/:id | Enable or disable: { enabled }. | Key |
| GET | /api/v1/usage | Usage and fees, last 30 days. | Key |
Errors
| Status | Code | Meaning |
|---|---|---|
400 | VALIDATION_ERROR | Body failed validation. The message lists each field. |
401 | UNAUTHORIZED | Missing, invalid, revoked or expired credential. |
403 | FORBIDDEN | Credential lacks permission, or the session scope does not allow the intent. |
404 | NOT_FOUND | Resource does not exist in your organization. |
409 | IDEMPOTENCY_CONFLICT | Idempotency key reused with a different body. |
429 | RATE_LIMITED | Too many requests. Retry-After header and error.retryAfterSeconds give the delay. |
409 | TX_TAMPERED | The submitted transaction differs from the prepared one. Nothing was sent. |
503 | PRICE_UNAVAILABLE | USD pricing unavailable. Nothing was created. |
Rate limits
| Scope | Limit | Notes |
|---|---|---|
| Owner token / signed-in user | 120 per minute | Per member. |
| API key | 600 per minute | Per key. |
| Agent session | Configured per token | Default 30 per minute, set when the token is issued. |
| Organization | 2,000 per minute | Across all credentials. |
| Loop protection | 12 actions per minute, 3 identical intents per 5 minutes, 500 per day | Per 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.
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.
curl -X POST "$ELYX_URL/api/v1/webhooks" \ -H "Authorization: Bearer $ELYX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/elyx/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 Elysium.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 elyx-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.
POST /elyx/webhookscontent-type: application/jsonuser-agent: ELYX-Webhooks/1elyx-signature: t=1791200000,v1=5f2c...e81a{ "id": "whd_...", "type": "agent.execution.settled", "createdAt": "<ISO 8601>", "data": { ... } }import { createHmac, timingSafeEqual } from "node:crypto";const TOLERANCE_S = 300;export function verifyElyxSignature(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 (!verifyElyxSignature(rawBody, req.headers.get("elyx-signature"), process.env.ELYX_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 elyx.webhooks.verify(rawBody, header, secret) returns { ok: true } or { ok: false, reason }.
