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.
Prepare the project
You need Node.js 18 or later and the test credentials from pilot onboarding.
mkdir savapay-quickstart
cd savapay-quickstart
npm init -y
npm install express dotenvConfigure the test environment
Create a .env file. Use the exact test URL and secrets provided in your onboarding package.
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.
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:
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:
{
"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 see | What to check |
|---|---|
401 authentication_required | Confirm that the issued test key is present and sent from your server. |
409 idempotency_conflict | Use the same key only when the order and payment request are unchanged. |
Webhook signature fails | Verify the raw body before JSON parsing and use the test signing secret. |
Checkout does not complete | Check the payment status and follow the test-customer steps from onboarding. |