Skip to content
StraventaDocs
Recipes

Build a hosted checkout page

Let 505pay host the payment UI — create an intent with your API key and redirect the payer to the 505pay checkout page

Execute curls interactively

To run these API calls directly from your browser, open the Payments API reference and use the built-in Try-it playground. Paste your sk_test_… key in the Auth panel, pick Sandbox, and send without leaving the docs.

The hosted checkout flow lets 505pay handle the entire payment UI. Your server mints a payment intent with your merchant API key, receives a checkout_url, and redirects the payer there. 505pay collects payment details, handles PSP redirects, and fires a webhook when done.

This is the fastest integration path — no frontend code required. The payer sees the 505pay-branded checkout page at pg.505pay.link (or your branded equivalent).

The hosted-checkout endpoint accepts your secret API key (sk_test_… in sandbox, sk_live_… in production). It also accepts a console JWT for backward compatibility with the dashboard, but server-to-server integrations should always use an API key. Create one at Settings → API keys in the dashboard.


Create a payment intent with your API key

Send your server-side sk_test_… (sandbox) or sk_live_… (production) key as the bearer. An Idempotency-Key header is required for API-key callers — re-using the same key with the same body replays the original response, so network retries are safe.

Attach metadata for any integrator-supplied bookkeeping (order id, customer ref, etc.). It round-trips on both the response and the payment_intent.created + payment.completed webhook events.

curl -X POST https://sandbox.505pay.link/v1/payments/intents \
  -H "Authorization: Bearer $PAYOPS_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9876-$(uuidgen)" \
  -d '{
    "merchant_id": "018f4d9f-7b8a-7c2d-9a2d-1f8b92f6d101",
    "amount_idr": 150000,
    "max_uses": 1,
    "description": "Order #9876",
    "metadata": {
      "order_id": "9876",
      "customer_ref": "cust_42"
    }
  }'

Response (201 Created):

{
  "id": "018f...",
  "shortcode": "abc123def456",
  "amount_idr": 150000,
  "status": "open",
  "metadata": { "order_id": "9876", "customer_ref": "cust_42" },
  "checkout_url": "https://pg.505pay.link/pay/abc123def456"
}
import express from 'express';
import { randomUUID } from 'node:crypto';

const app = express();
app.use(express.json());

const PAYMENTS_BASE = 'https://sandbox.505pay.link';
const SK = process.env.PAYOPS_SECRET_KEY; // sk_test_... or sk_live_...

app.post('/checkout/start', async (req, res) => {
  const { orderId } = req.body;
  const r = await fetch(`${PAYMENTS_BASE}/v1/payments/intents`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${SK}`,
      'Content-Type': 'application/json',
      // Tie the idempotency key to your order id so retrying a failed
      // request returns the SAME intent instead of minting a new one.
      'Idempotency-Key': `order-${orderId}`,
    },
    body: JSON.stringify({
      merchant_id: process.env.PAYOPS_MERCHANT_ID,
      amount_idr: 150_000,
      max_uses: 1,
      description: `Order #${orderId}`,
      metadata: { order_id: orderId },
    }),
  });
  if (!r.ok) {
    const err = await r.json();
    return res.status(r.status).json(err);
  }
  const intent = await r.json();
  // Persist intent.id alongside your order BEFORE redirecting, so the
  // webhook handler can resolve back to the order.
  await persistIntent({ orderId, intentId: intent.id });
  res.redirect(303, intent.checkout_url);
});
import os
import requests
from flask import Flask, request, redirect, jsonify

app = Flask(__name__)
PAYMENTS_BASE = 'https://sandbox.505pay.link'
SK = os.environ['PAYOPS_SECRET_KEY']  # sk_test_... or sk_live_...

@app.post('/checkout/start')
def start_checkout():
    order_id = request.json['orderId']
    r = requests.post(
        f'{PAYMENTS_BASE}/v1/payments/intents',
        headers={
            'Authorization': f'Bearer {SK}',
            # Tie idempotency key to the order id — retries return the SAME intent.
            'Idempotency-Key': f'order-{order_id}',
        },
        json={
            'merchant_id': os.environ['PAYOPS_MERCHANT_ID'],
            'amount_idr': 150_000,
            'max_uses': 1,
            'description': f'Order #{order_id}',
            'metadata': {'order_id': order_id},
        },
        timeout=10,
    )
    if not r.ok:
        return jsonify(r.json()), r.status_code
    intent = r.json()
    # Persist intent.id alongside your order BEFORE redirecting.
    persist_intent(order_id=order_id, intent_id=intent['id'])
    return redirect(intent['checkout_url'], code=303)
package main

import (
	"bytes"
	"encoding/json"
	"net/http"
	"os"
)

const paymentsBase = "https://sandbox.505pay.link"

func startCheckout(w http.ResponseWriter, r *http.Request) {
	var in struct{ OrderID string `json:"orderId"` }
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, err.Error(), 400)
		return
	}

	body, _ := json.Marshal(map[string]any{
		"merchant_id": os.Getenv("PAYOPS_MERCHANT_ID"),
		"amount_idr":  150_000,
		"max_uses":    1,
		"description": "Order #" + in.OrderID,
		"metadata":    map[string]string{"order_id": in.OrderID},
	})

	req, _ := http.NewRequest(http.MethodPost,
		paymentsBase+"/v1/payments/intents", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("PAYOPS_SECRET_KEY"))
	req.Header.Set("Content-Type", "application/json")
	// Tie the idempotency key to the order id — retries return the SAME intent.
	req.Header.Set("Idempotency-Key", "order-"+in.OrderID)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, err.Error(), 502)
		return
	}
	defer resp.Body.Close()

	var intent struct {
		ID          string `json:"id"`
		CheckoutURL string `json:"checkout_url"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&intent); err != nil {
		http.Error(w, err.Error(), 502)
		return
	}
	// Persist intent.ID alongside your order BEFORE redirecting.
	persistIntent(in.OrderID, intent.ID)
	http.Redirect(w, r, intent.CheckoutURL, http.StatusSeeOther)
}

Payer completes payment on the hosted page

505pay handles the payment UI at the checkout_url. No frontend work needed on your side.

Receive the payment.completed webhook

Fulfilment must trigger on the webhook, never on the redirect. The payment.completed event echoes your metadata so you can resolve back to your order without a side-table lookup.

{
  "event": "payment.completed",
  "intent_id": "018f...",
  "merchant_id": "018f...",
  "shortcode": "abc123def456",
  "amount_idr": 150000,
  "status": "paid",
  "completed_at": "2026-05-15T12:34:56Z",
  "metadata": { "order_id": "9876", "customer_ref": "cust_42" }
}

See the Webhooks guide for the signature verification recipe and the canonical event list.

Always verify the payment.completed webhook signature before fulfilling. The redirect back to your site is a UX hint, not a payment confirmation.

Handle replay and drift

  • Same Idempotency-Key + same body → 201 Created with the original response (the Idempotent-Replayed: true header is set). Safe to retry on network blips.
  • Same Idempotency-Key + different body → 409 idempotency_key_mismatch. Surface this as a bug in your retry logic; do not strip the key and retry.
  • Missing Idempotency-Key from an API-key caller → 400 idempotency_key_required.

See the Idempotency guide for the full algorithm.

Was this page helpful?

On this page