# Integrate Webhook

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

- Skill: `solana-foundation/integrate-webhook` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add solana-foundation/integrate-webhook`
- Raw SKILL.md: https://api.skillmd.com/api/skills/solana-foundation/integrate-webhook/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: solana-foundation (https://skillmd.com/u/solana-foundation)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/solana-foundation/integrate-webhook

---


# Integrate webhook

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:

| Provider | Header | Algorithm | Env keys |
|---|---|---|---|
| Lightspark | `x-grid-signature` | ECDSA P-256 / SHA-256 (public key) | `LIGHTSPARK_GRID_WEBHOOK_PUBLIC_KEY` / `LIGHTSPARK_GRID_SANDBOX_WEBHOOK_PUBLIC_KEY` |
| MoonPay | `moonpay-signature-v2` | HMAC-SHA256 (hex, `t=…,s=…`) | `MOONPAY_WEBHOOK_KEY` / `MOONPAY_SANDBOX_WEBHOOK_KEY` |
| BVNK | `x-signature` | HMAC-SHA256 (base64) | `BVNK_WEBHOOK_SECRET` / `BVNK_SANDBOX_WEBHOOK_SECRET` |
| Coinbase | `x-hook0-signature` | HMAC-SHA256 (hex, `t=…,v0=…`) | `COINBASE_CDP_RAMPS_WEBHOOK_SECRET` |
| Mural | `x-mural-webhook-signature` + timestamp header | ECDSA P-256 / SHA-256 (public key, base64) | `MURAL_PAY_WEBHOOK_PUBLIC_KEY` / `MURAL_PAY_SANDBOX_WEBHOOK_PUBLIC_KEY` |
| Stripe | `stripe-signature` | HMAC-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:

```ts
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.

