SavaPay Developers

Quickstart

Create a payment request, send a customer to hosted checkout, and verify the final result from your server.

Get startedNode.js and ExpressApproved merchant pilot
Browse developer documentationQuickstart

Build a small Node.js server that creates a payment request, sends the customer to hosted checkout, verifies the signed result, and confirms final payment status.

Estimated reading time10 minutes
EnvironmentPilot test environment
ResultVerified payment request

Prepare the project

You need Node.js 18 or later and the test credentials from pilot onboarding.

Terminal
mkdir savapay-quickstart
cd savapay-quickstart
npm init -y
npm install express dotenv

Configure the test environment

Create a .env file. Use the exact test URL and secrets provided in your onboarding package.

.env
SAVAPAY_SECRET_KEY=sp_your_issued_test_key
SAVAPAY_WEBHOOK_SECRET=your_test_webhook_signing_secret
SAVAPAY_API_URL=<test URL from your SavaPay onboarding package>

Build the server

Create server.js. This example uses Node's built-in fetch, creates retry-safe requests, verifies the raw webhook body, and confirms status before fulfilment.

server.js
require('dotenv').config()

const crypto = require('crypto')
const express = require('express')

const app = express()
const apiUrl = process.env.SAVAPAY_API_URL

async function savapayRequest(path, options = {}) {
  const response = await fetch(`${apiUrl}${path}`, {
    method: options.method || 'GET',
    headers: {
      Authorization: `Bearer ${process.env.SAVAPAY_SECRET_KEY}`,
      'Content-Type': 'application/json',
      ...(options.idempotencyKey
        ? { 'Idempotency-Key': options.idempotencyKey }
        : {}),
    },
    body: options.body ? JSON.stringify(options.body) : undefined,
  })

  const result = await response.json()
  if (!response.ok) {
    const error = new Error(result.error || 'SavaPay request failed')
    error.code = result.code
    error.status = response.status
    throw error
  }
  return result
}

// Register the webhook before express.json(). Signature verification needs
// the exact raw bytes sent by SavaPay.
app.post(
  '/savapay/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-savapay-signature']
    const eventId = req.headers['x-savapay-event-id']
    const rawBody = req.body.toString('utf8')
    const event = JSON.parse(rawBody)

    const expected = crypto
      .createHmac('sha256', process.env.SAVAPAY_WEBHOOK_SECRET)
      .update(`${eventId}.${event.createdAt}.${rawBody}`)
      .digest('hex')

    const expectedBytes = Buffer.from(expected, 'hex')
    const receivedBytes = Buffer.from(signature || '', 'hex')
    const valid =
      expectedBytes.length === receivedBytes.length &&
      crypto.timingSafeEqual(expectedBytes, receivedBytes)

    if (!valid) return res.status(400).send('invalid signature')

    if (event.type === 'payment.succeeded') {
      const intent = await savapayRequest(
        `/payment-intents/${event.data.paymentIntentId}`
      )

      if (intent.status === 'succeeded') {
        // Replace this with a durable, duplicate-safe order update.
        console.log('Payment confirmed:', intent.id)
      }
    }

    res.status(200).send('ok')
  }
)

app.use(express.json())

app.post('/checkout', async (req, res) => {
  const { amount, orderId } = req.body

  try {
    const intent = await savapayRequest('/payment-intents', {
      method: 'POST',
      idempotencyKey: `checkout-${orderId}`,
      body: {
        amount,
        currency: 'USD',
        merchantReference: orderId,
        description: `Order ${orderId}`,
        webhookUrl: 'https://merchant.example.com/savapay/webhook',
      },
    })

    res.status(201).json({
      paymentIntentId: intent.id,
      checkoutUrl: intent.checkoutUrl,
    })
  } catch (error) {
    res.status(error.status || 500).json({
      code: error.code || 'internal_error',
      error: error.message,
    })
  }
})

app.get('/payments/:id', async (req, res) => {
  try {
    const intent = await savapayRequest(
      `/payment-intents/${req.params.id}`
    )
    res.json({ status: intent.status })
  } catch (error) {
    res.status(error.status || 500).json({
      code: error.code || 'internal_error',
      error: error.message,
    })
  }
})

app.listen(4242, () => {
  console.log('Quickstart running at http://localhost:4242')
})

Create a test payment request

Start the server with node server.js, then create a $12.99 test payment:

Terminal
curl http://localhost:4242/checkout \
  --request POST \
  --header "Content-Type: application/json" \
  --data '{"amount":1299,"orderId":"order-123"}'

The response contains the payment ID you store and the checkout URL you open:

Response
{
  "paymentIntentId": "pi_1abc",
  "checkoutUrl": "https://pay.savapay.io/checkout/<token>"
}

Send the customer to checkoutUrl. Follow the test-customer approval steps supplied during onboarding.

Verify success safely

Your basic test passes when all of these statements are true. Production readiness requires additional checks:

  • The checkout amount matches the amount created by your server.
  • Your webhook handler accepts the valid signed event.
  • An invalid signature receives a 400 response.
  • The status endpoint returns succeeded.
  • Retrying the same order does not create a second payment.

Troubleshooting

What you seeWhat to check
401 authentication_requiredConfirm that the issued test key is present and sent from your server.
409 idempotency_conflictUse the same key only when the order and payment request are unchanged.
Webhook signature failsVerify the raw body before JSON parsing and use the test signing secret.
Checkout does not completeCheck the payment status and follow the test-customer steps from onboarding.

Next

Test the failure paths

Continue with these resources before production: Sandbox checklist webhook reliability

Maintained byDeveloper Experience

Last reviewed

Was this page helpful?