Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.solvapay.com/llms.txt

Use this file to discover all available pages before exploring further.

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 all 13 event names
// 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 the following business events. Only meaningful outcome events are sent — you will not receive events for internal CRUD operations on products, plans, or other configuration entities.

Payment Events

EventTriggerDescription
payment.succeededStripe confirms paymentA payment has been successfully processed.
payment.failedStripe reports failureA payment attempt has failed.
payment.refundedRefund completedA refund has been successfully processed.
payment.refund_failedRefund rejectedA refund attempt has failed.

Purchase Events

EventTriggerDescription
purchase.createdPurchase record createdA new purchase (subscription or one-time) has been created. Also fires when a plan switch creates the new purchase.
purchase.updatedStatus or fields changeA purchase has been modified (e.g. plan change, renewal, or reactivation — cancelledAt cleared and autoRenew restored).
purchase.cancelledExplicit cancel or period endA purchase has been cancelled.
purchase.expiredEnd date reached or plan switchA purchase has expired naturally, or was expired by a plan switch replacing it with a new purchase.
purchase.suspendedPayment overdue / trial expiredA purchase has been suspended due to non-payment.
Reactivation: When a pending cancellation is undone via reactivateRenewal, a purchase.updated event fires with cancelledAt cleared and autoRenew restored to true.Plan switching: When activatePlan is called with a different plan than the customer’s current purchase, two events fire: purchase.expired for the old purchase and purchase.created for the new one.

Checkout Events

EventTriggerDescription
checkout_session.createdCheckout session created via API or dashboardA new checkout session has been created for a customer.

Customer Events

EventTriggerDescription
customer.createdCustomer created via hosted auth, API, or SDK syncA new customer record has been created.
customer.updatedCustomer details changedA customer record has been updated.
customer.deletedCustomer removedA customer record has been deleted.

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 MCP Pay 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 MCP Pay 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
  }
}

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.