Skip to main content

Overview

SolvaPay webhooks push event notifications to your application via HTTP POST requests whenever significant business events occur — a payment succeeds, a purchase is cancelled, a customer is created, and so on. Instead of polling the API, you register an endpoint URL and SolvaPay delivers a signed JSON payload for each event. Your server verifies the signature, processes the event, and returns a 2xx response.

How it works

SolvaPay Backend
    ↓  event occurs (payment, purchase, customer)
Enqueue WebhookEvent + WebhookDelivery
    ↓  cron every 30 s, up to 20 per batch
POST to your endpoint

Your server verifies SV-Signature

Process event

Return 2xx

Quick Start

1. Install the SDK

npm install @solvapay/server

2. Create a webhook endpoint

In SolvaPay Console, go to Settings -> Webhooks and add your endpoint URL:
https://your-app.com/api/webhooks/solvapay
Copy the signing secret (whsec_…) — you will need it to verify signatures.

3. Handle incoming webhooks

// app/api/webhooks/solvapay/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyWebhook } from '@solvapay/server'

export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('sv-signature')

  if (!signature) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 401 })
  }

  try {
    const event = verifyWebhook({
      body,
      signature,
      secret: process.env.SOLVAPAY_WEBHOOK_SECRET!,
    })

    switch (event.type) {
      case 'payment.succeeded':
        // fulfil the order
        break
      case 'purchase.cancelled':
        // revoke access
        break
      default:
        console.log('Unhandled event:', event.type)
    }

    return NextResponse.json({ received: true })
  } catch (error) {
    console.error('Webhook verification failed:', error)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
  }
}
Always use request.text() (Next.js) or express.raw() (Express) so the body is available as a raw string. If the body has been JSON-parsed before verification, the signature check will fail.

Payload Format

Every webhook POST contains a JSON body with this structure:
{
  "id": "evt_abc123def456",
  "type": "payment.succeeded",
  "created": 1740000000,
  "api_version": "2025-10-01",
  "data": {
    "object": { ... },
    "previous_attributes": null
  },
  "livemode": false,
  "request": {
    "id": null,
    "idempotency_key": null
  }
}

HTTP Headers

HeaderExampleDescription
SV-Signaturet=1740000000,v1=a1b2c3…HMAC signature for verification
SV-Event-Idevt_abc123def456Unique event identifier
SV-Deliverydlv_xyz789Unique delivery attempt ID (use for idempotency)
Content-Typeapplication/jsonAlways JSON
User-AgentSolvaPay/1.0 (+webhooks)Identifies the sender

Signature Verification

Every webhook includes an SV-Signature header. You must verify it to confirm the request genuinely came from SolvaPay and has not been tampered with.

How the signature is computed

  1. SolvaPay takes the current Unix timestamp and the raw JSON body.
  2. It concatenates them as "{timestamp}.{rawBody}".
  3. It computes an HMAC-SHA256 of that string using your signing secret (including the whsec_ prefix) as the key.
  4. The result is sent as t={timestamp},v1={hex_digest}.

Using the SDK

verifyWebhook returns a typed WebhookEvent object with full IntelliSense for event types and payload fields:
import { verifyWebhook, type WebhookEvent } from '@solvapay/server'

const event: WebhookEvent = verifyWebhook({
  body: rawBodyString,
  signature: svSignatureHeader,
  secret: process.env.SOLVAPAY_WEBHOOK_SECRET!,
})

// event.type is typed as WebhookEventType — autocomplete for every event name
// event.data.object contains the event-specific payload
verifyWebhook throws a SolvaPayError if the signature is invalid or older than 5 minutes.

Edge Runtimes

For Vercel Edge Functions, Cloudflare Workers, or Deno Deploy, import from the edge entry point. It uses the Web Crypto API and returns a Promise:
import { verifyWebhook } from '@solvapay/server/edge'

const event = await verifyWebhook({
  body: rawBodyString,
  signature: svSignatureHeader,
  secret: process.env.SOLVAPAY_WEBHOOK_SECRET!,
})

Manual verification (without the SDK)

import crypto from 'node:crypto'

function verify(body: string, header: string, secret: string): boolean {
  const parts = header.split(',')
  const tPart = parts.find(p => p.startsWith('t='))
  const v1Part = parts.find(p => p.startsWith('v1='))
  if (!tPart || !v1Part) return false

  const timestamp = tPart.slice(2)
  const signature = v1Part.slice(3)

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  )
}

Event Types

SolvaPay emits 46 event types spanning the full lifecycle — from customer and purchase creation through payments, refunds, disputes, payouts, credits, metered usage, and catalog changes. The canonical list is also available programmatically from GET /v1/sdk/webhooks/event-types and is what powers both the SDK WebhookEventType type and the event multiselect in the Console.
By default an endpoint receives all events. To narrow it down, see Choosing which events to receive.

Customer Events

EventDescription
customer.createdA new customer record has been created (hosted auth, API, or SDK sync).
customer.updatedA customer record has been updated.
customer.deletedA customer record has been deleted.

Purchase & Subscription Events

The complete purchase/subscription state machine:
EventDescription
purchase.createdA new purchase (subscription or one-time) was created. Includes trial starts.
purchase.activatedA purchase activated after its first successful payment.
purchase.updatedA purchase was modified (generic change not covered by a more specific event).
purchase.trial_endingA trial is ending soon.
purchase.trial_convertedA trial converted to a paid subscription.
purchase.suspendedA purchase was suspended (trial ended, payment required).
purchase.past_dueA recurring payment failed and dunning has started.
purchase.cancellation_scheduledCancellation scheduled for the end of the billing period.
purchase.cancelledA purchase was cancelled.
purchase.reactivatedA scheduled cancellation was reversed.
purchase.expiredA purchase expired (naturally, or superseded by a plan switch).
purchase.renewedA subscription successfully renewed (recurring charge).
purchase.renewal_reminderThe upcoming-renewal reminder window was reached.
purchase.refundedA purchase’s entitlement was revoked by a full refund.
purchase.plan_changedA subscription was upgraded or downgraded to a new plan.

Payment, Refund & Dispute Events

EventDescription
payment.succeededA payment was successfully processed.
payment.failedA payment attempt failed.
payment.refundedA refund was successfully processed.
payment.refund_failedA refund attempt failed.
payment.refund_pendingA refund was initiated and is still in-flight.
payment.canceledA payment intent was canceled.
payment.disputedA chargeback/dispute was opened.
payment.dispute_closedA dispute was resolved (won or lost).

Payout Events

EventDescription
payout.paidA provider payout settled.
payout.failedA provider payout failed.

Checkout Session Events

EventDescription
checkout_session.createdA hosted checkout session was created.
checkout_session.completedA hosted checkout session was completed (converted).
checkout_session.expiredA hosted checkout session expired (abandoned).

Credit Events

EventDescription
customer.credit.topped_upA customer’s credit balance was topped up.
customer.credit.low_balanceA customer’s credit balance is running low.
customer.credit.exhaustedA customer’s credit balance reached zero.
customer.credit.debitedA customer’s credit was debited by usage.
customer.credit.grantedFree credit was granted to a customer.
customer.credit.adjustedA customer’s credit balance was manually adjusted.

Usage & Metering Events

EventDescription
usage.chargedA metered / usage-based charge was processed.
usage.recordedA metered usage event was recorded.
usage.resetUsage counters were reset for a new period.

Catalog Events

EventDescription
product.createdA product was created.
product.updatedA product was updated.
product.archivedA product was archived.
plan.createdA plan was created.
plan.updatedA plan was updated.
plan.archivedA plan was archived.
Reactivation: undoing a pending cancellation via reactivateRenewal emits purchase.reactivated.Plan switching: calling activatePlan with a different plan emits purchase.expired for the old purchase, purchase.created for the new one, and purchase.plan_changed.

Choosing which events to receive

By default, every endpoint receives all event types. You can subscribe an endpoint to a specific subset so your handler only gets the events it cares about.

In the Console

Under Settings → Webhooks, expand an endpoint and pick Selected events to choose specific event types (grouped by category). Leave it on All events to receive everything — including any new event types SolvaPay adds later.

Via the API

Pass enabledEvents when creating or updating an endpoint. An empty or omitted array means “all events”; a non-empty array subscribes to only those types.
# Subscribe to only payment + purchase lifecycle events
curl -X POST https://api.solvapay.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/api/webhooks/solvapay",
    "enabledEvents": [
      "payment.succeeded",
      "payment.failed",
      "purchase.created",
      "purchase.cancelled",
      "purchase.past_due"
    ]
  }'
Subscribing to a subset is purely a delivery filter — it does not change the payload shape or signing. Endpoints left on “all events” automatically receive new event types as they are introduced, so you never miss a future event.

Event Payloads

payment.succeeded

{
  "type": "payment.succeeded",
  "data": {
    "object": {
      "id": "pi_abc123",
      "amount": 2999,
      "currency": "usd",
      "customer": "cust_ref_001",
      "product": "prod_ref_001",
      "plan": "plan_ref_001",
      "stripe_payment_intent": "pi_3abc...",
      "transaction_reference": "txn_ref_001",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

payment.failed

{
  "type": "payment.failed",
  "data": {
    "object": {
      "id": "pi_abc123",
      "amount": 2999,
      "currency": "usd",
      "customer": "cust_ref_001",
      "product": "prod_ref_001",
      "plan": "plan_ref_001",
      "stripe_payment_intent": "pi_3abc...",
      "transaction_reference": "txn_ref_001",
      "failure_reason": "Your card was declined.",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

payment.refunded

{
  "type": "payment.refunded",
  "data": {
    "object": {
      "id": "txn_abc123",
      "amount": 2999,
      "currency": "usd",
      "stripe_refund": "re_3abc...",
      "reason": "requested_by_customer",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

payment.refund_failed

{
  "type": "payment.refund_failed",
  "data": {
    "object": {
      "id": "txn_abc123",
      "amount": 2999,
      "currency": "usd",
      "stripe_refund": "re_3abc...",
      "reason": "requested_by_customer",
      "failure_reason": "charge_for_pending_refund_disputed",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

purchase.created

{
  "type": "purchase.created",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "active",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

purchase.updated

{
  "type": "purchase.updated",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "active",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

purchase.cancelled

{
  "type": "purchase.cancelled",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "cancelled",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

purchase.expired

{
  "type": "purchase.expired",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "expired",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

purchase.suspended

{
  "type": "purchase.suspended",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "suspended",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

customer.created

This payload can include a product field with a product reference. SolvaPay only populates product when the customer is created through the no-code MCP integration’s hosted OAuth for a product-linked client. To learn more about SDK customer syncing, see /sdks/typescript/setup/core-concepts.
{
  "type": "customer.created",
  "data": {
    "object": {
      "id": "cust_abc123",
      "reference": "customer_abc123",
      "name": "Jane Doe",
      "email": "jane@example.com",
      "telephone": "+1234567890",
      "status": "active",
      "created": 1740000000,
      "product": {
        "reference": "prod_ref_001"
      }
    },
    "previous_attributes": null
  }
}

customer.updated

{
  "type": "customer.updated",
  "data": {
    "object": {
      "id": "cust_abc123",
      "reference": "customer_abc123",
      "name": "Jane Smith",
      "email": "jane.smith@example.com",
      "telephone": "+1234567890",
      "status": "active",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

customer.deleted

The customer.deleted payload contains only the customer id and a created timestamp. Other fields are not included since the record has been removed.
{
  "type": "customer.deleted",
  "data": {
    "object": {
      "id": "cust_abc123",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

Customer sync with the TypeScript SDK

When you integrate with the TypeScript SDK (instead of the no-code MCP integration’s hosted OAuth), sync your users with ensureCustomer or the Next.js syncCustomer helper. This flow is idempotent. SolvaPay first looks up a customer by externalRef, then creates one if none exists. When a new customer is created this way, SolvaPay sends a customer.created webhook event with product set to null.
// Server SDK
const customerRef = await solvaPay.ensureCustomer('user_123', 'user_123', {
  email: 'user@example.com',
  name: 'Jane Doe',
})
// Next.js route handler
import { NextRequest, NextResponse } from 'next/server'
import { syncCustomer } from '@solvapay/next'

export async function POST(request: NextRequest) {
  const result = await syncCustomer(request)
  return result instanceof NextResponse ? result : NextResponse.json(result)
}
For implementation details, see /sdks/typescript/setup/core-concepts and /sdks/typescript/guides/nextjs.

checkout_session.created

{
  "type": "checkout_session.created",
  "data": {
    "object": {
      "id": "cs_abc123",
      "session_id": "a1b2c3d4e5f6...",
      "url": "https://solvapay.com/customer/checkout?id=a1b2c3d4e5f6...",
      "status": "active",
      "amount": 2999,
      "currency": "usd",
      "customer": "cust_ref_001",
      "product": "prod_ref_001",
      "plan": "plan_ref_001",
      "expires_at": 1740000900,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
checkout_session.completed and checkout_session.expired carry the same object shape, with status set to used and expired respectively.

Purchase lifecycle events

All purchase.* events share the purchase object shape shown under purchase.created; status reflects the new state. Two events add extra context:
// purchase.past_due — a recurring charge failed; dunning has begun
{
  "type": "purchase.past_due",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "past_due",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "retryCount": 1,
      "nextRetryAt": 1740018000,
      "created": 1740000000
    },
    "previous_attributes": { "status": "active" }
  }
}
// purchase.plan_changed — upgrade/downgrade; previous_attributes shows the old plan
{
  "type": "purchase.plan_changed",
  "data": {
    "object": {
      "id": "pur_def456",
      "reference": "purchase_def456",
      "status": "active",
      "customer": "cust_ref_001",
      "plan": "plan_pro_001",
      "product": "prod_ref_001",
      "created": 1740000000
    },
    "previous_attributes": { "plan": "plan_basic_001" }
  }
}

Dispute & payout events

// payment.disputed — a chargeback was opened (payment.dispute_closed has the same shape)
{
  "type": "payment.disputed",
  "data": {
    "object": {
      "id": "dp_abc123",
      "amount": 2999,
      "currency": "usd",
      "reason": "fraudulent",
      "status": "needs_response",
      "stripe_dispute": "dp_3abc...",
      "stripe_charge": "ch_3abc...",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
// payout.paid — a provider payout settled (payout.failed has the same shape)
{
  "type": "payout.paid",
  "data": {
    "object": {
      "id": "po_abc123",
      "amount": 184500,
      "currency": "usd",
      "status": "paid",
      "stripe_payout": "po_3abc...",
      "arrival_date": 1740086400,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

Credit events

// customer.credit.debited — credits consumed by usage
// (granted / adjusted / topped_up / low_balance / exhausted share this shape)
{
  "type": "customer.credit.debited",
  "data": {
    "object": {
      "customerId": "cust_ref_001",
      "providerId": "prov_001",
      "credits": 120,
      "amount": -5,
      "productRef": "prod_ref_001"
    },
    "previous_attributes": null
  }
}
credits is the running balance after the movement; amount is the signed delta. customer.credit.low_balance and customer.credit.exhausted fire automatically as the balance crosses the warning threshold and zero.

Usage & metering events

// usage.recorded — a metered usage event was recorded
{
  "type": "usage.recorded",
  "data": {
    "object": {
      "reference": "usg_abc123",
      "customer": "cust_ref_001",
      "product": "prod_ref_001",
      "purchase": "purchase_abc123",
      "quantity": 1,
      "meterName": "api_requests",
      "recordedAt": 1740000000
    },
    "previous_attributes": null
  }
}
usage.reset fires when counters roll over for a new billing period.

Catalog events

// product.created — product.updated / product.archived share this shape
{
  "type": "product.created",
  "data": {
    "object": {
      "id": "prd_abc123",
      "reference": "prod_ref_001",
      "name": "AI Writing Assistant",
      "status": "active",
      "productType": "mcp",
      "isMcpPay": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
// plan.created — plan.updated / plan.archived share this shape
{
  "type": "plan.created",
  "data": {
    "object": {
      "id": "pln_abc123",
      "reference": "plan_ref_001",
      "name": "Pro",
      "status": "active",
      "kind": "recurring",
      "price": 2999,
      "currency": "usd",
      "requiresPayment": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}

Best Practices

Never process a webhook without checking SV-Signature. Without verification, any third party could forge requests to your endpoint.
The SV-Delivery header is unique per delivery attempt. Store processed delivery IDs and skip duplicates to avoid processing the same event twice.
Return a 2xx as quickly as possible. If your handler needs to do heavy processing (e.g. sending emails, updating external systems), acknowledge the webhook immediately and move the work to a background queue.
Log and return 200 for event types you don’t recognise. This way the delivery is marked successful and SolvaPay won’t retry it.
Signature verification requires the exact body bytes. Always read the body as a raw string (request.text() or express.raw()) before parsing it as JSON.

Retry Schedule

If your endpoint returns a non-2xx status code or times out, SolvaPay retries delivery with exponential backoff:
RetryDelay after previous
15 minutes
215 minutes
31 hour
46 hours
524 hours
648 hours
7+72 hours
After 12 failed attempts the endpoint is automatically disabled. You can re-enable it from SolvaPay Console.

Testing Locally

Using ngrok

# Start your local server
npm run dev

# In another terminal
ngrok http 3000

# Copy the HTTPS URL and add it as a webhook endpoint in the dashboard

Sending a test event

Use the dashboard test button, or call the API directly:
curl -X POST https://api.solvapay.com/v1/ui/webhooks/{endpointId}/test \
  -H "Authorization: Bearer $TOKEN"
This sends a payment.succeeded test event to the endpoint so you can verify your handler and signature verification are working correctly.