Integrate webhook

SkillCommerce & finance

Implement and register a ramp WebhookProcessor in sdp-api with raw-request verification, replay protection, typed @sdp/payments parsing, and idempotent settlement.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Integrate webhook skill

What this skill tells your AI

The instructions your AI receives, as published by solana-foundation/solana-developer-platform in .agents/skills/integrate-webhook/SKILL.md and read by ahel’s review.

Webhooks drive a transfer's lifecycle after the quote: awaiting_payment → settling → completed | failed | expired. Implement one WebhookProcessor in apps/sdp-api/src/routes/webhooks/ramps/<id>.ts using the interface in processor.ts:

  • verify(context) — verify the signature, return the parsed payload.
  • parse(payload) — pure wire-format → RampSettlementEvent mapping.
  • process(c, environment, event) — DB orchestration; calls applyRampSettlementEvent.

Canonical examples: the registered processors in apps/sdp-api/src/routes/webhooks/ramps/ — read the closest one before writing yours.

Mount + flow

Webhooks are not under /v1. They land at POST /webhooks/payments/ramps/{sandbox|production}/:provider. parseRampWebhookProvider accepts an id only after registration in apps/sdp-api/src/routes/webhooks/handlers.ts. The handler reads the raw body → verifies → parses → returns 2xx → runs process through c.executionCtx.waitUntil.

verify

verify(context: RampWebhookValidationContext): Promise<Payload>. context = { env, environment, headers, rawBody, requestUrl? }. Read the mode-keyed verification secret/key from env, verify the signature over the raw body, then JSON.parse and return the payload. Throw UNAUTHORIZED on a missing/invalid signature; throw badRequest on non-JSON. Use apps/sdp-api/src/lib/webhook-signature.ts and supply the upstream timestamp as timestampSeconds; the helper enforces replay tolerance as well as signature verification.

The shared verifier currently supports HMAC-SHA256 and ECDSA P-256/SHA-256, and it requires a provider-signed timestamp. If the upstream uses another algorithm or does not sign a timestamp, stop and extend/review the shared verification contract; do not fabricate a local timestamp or bypass replay protection.

This is one of the few places unknown is allowed — it's a genuine trust boundary, narrowed immediately by parse.

Signature variety across the six registered webhook providers:

ProviderHeaderAlgorithmEnv keys
Lightsparkx-grid-signatureECDSA P-256 / SHA-256 (public key)LIGHTSPARK_GRID_WEBHOOK_PUBLIC_KEY / LIGHTSPARK_GRID_SANDBOX_WEBHOOK_PUBLIC_KEY
MoonPaymoonpay-signature-v2HMAC-SHA256 (hex, t=…,s=…)MOONPAY_WEBHOOK_KEY / MOONPAY_SANDBOX_WEBHOOK_KEY
BVNKx-signatureHMAC-SHA256 (base64)BVNK_WEBHOOK_SECRET / BVNK_SANDBOX_WEBHOOK_SECRET
Coinbasex-hook0-signatureHMAC-SHA256 (hex, t=…,v0=…)COINBASE_CDP_RAMPS_WEBHOOK_SECRET
Muralx-mural-webhook-signature + timestamp headerECDSA P-256 / SHA-256 (public key, base64)MURAL_PAY_WEBHOOK_PUBLIC_KEY / MURAL_PAY_SANDBOX_WEBHOOK_PUBLIC_KEY
Stripestripe-signatureHMAC-SHA256 (hex, t=…,v1=…)STRIPE_WEBHOOK_SECRET

parse

(payload: Payload) → Event, typically RampSettlementEvent from @sdp/payments/ramps/types. Narrow the payload with readString / readRecord / readNumber from @sdp/payments/json. Map upstream event types to kind, and set reference to the quote id persisted by SDP; that is how settlement finds the transfer. Anything legitimately signed but irrelevant maps to { provider, kind: "ignore", reason }.

parse runs synchronously before the 2xx ack, so it must be total over every payload the provider can legitimately sign: unknown event types and transactions the platform didn't create (sandbox tests, manual payments on the same account) must map to an ignore event or an absent reference — never a throw, which turns into a non-2xx and a provider retry loop. Reserve throws for payloads that violate the provider's own guaranteed envelope (e.g. a missing event type), where a loud deterministic failure is the point. Example: references not minted by SDP return undefined from the processor's reference reader and get logged-and-skipped in process.

RampSettlementEvent (packages/sdp-payments/src/ramps/types.ts):


`receivedAmount` is a major/display-unit string. For a settled on-ramp it is the received crypto amount; for an off-ramp it is the received fiat amount.
| { kind: "awaiting_payment"; provider; reference }
| { kind: "settling";         provider; reference }
| { kind: "settled";          provider; reference; receivedAmount?; settlement? }
| { kind: "failed";           provider; reference; error?; settlement? }
| { kind: "expired";          provider; reference; error?; settlement? }
| { kind: "ignore";           provider; reason }

process

process(c, environment, event) is thin — ignore or apply:

async process(c: AppContext, _environment: SdpEnvironment, event: RampSettlementEvent) {
  if (event.kind === "ignore") return;
  await applyRampSettlementEvent(c, event);
}

Then register the class in RAMP_PROVIDER_WEBHOOK_PROCESSOR in apps/sdp-api/src/routes/webhooks/handlers.ts, or explicitly extend the excluded no-webhook provider union. applyRampSettlementEvent finds the transfer by (provider, reference), skips terminal rows on redelivery, maps kind → status, persists received amounts for the relevant direction plus settlement economics, and records failed/expired errors.

Beyond settlement: provisioning auto-advance

A provider whose counterparty provisioning is async (KYC verification, wallet creation) also moves requirement state from webhooks: parse returns a wider provider event union — one event kind per upstream type (customer status changes, wallet creation/status changes, payment status changes) — and process switches exhaustively on event.kind, routing settlement kinds to applyRampSettlementEvent-based helpers and provisioning kinds to helpers that update counterparty_provider_accounts rows (never counterparties.provider_data — it's deprecated). The requirements GET then reflects the advanced state (counterparty-requirements). The standard contract a new provider implements is the settlement path above; add provisioning kinds only when the upstream provisions asynchronously.

Rules + verify

Shared rules live in integrate-ramp-provider. Hot here:

  • Verify the signature before trusting anything; never skip on a missing header — throw UNAUTHORIZED.
  • No swallowed errors in your own logic; the orchestration owns the 2xx + background write.
  • Event-type maps are as const satisfies Record<string, RampSettlementEvent["kind"]>; no any past the verify boundary.
  • Verify focused webhook tests for valid, missing, malformed, stale, and replayed signatures; unknown events; duplicate terminal delivery; and settlement persistence. Run API typecheck/tests plus @sdp/payments checks when event types change.

Signals

GitHub stars
53
Forks
23
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
integrate-webhook
Source
github.com/solana-foundation/solana-developer-platform