Skip to content
StraventaDocs
Recipes

Accept your first payment

End-to-end — create a payment intent with your sandbox API key, display the hosted checkout, fulfil on the webhook

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.

This recipe gets you from zero to a confirmed payment in your sandbox. It uses QRIS Dynamic (simplest integration — no redirect, payer just scans a QR generated from the response).

What you'll build: server creates an intent with its sandbox API key → receives a checkout_url + qr_string → payer scans → server receives payment.completed webhook → fulfils the order.

Prerequisites:

  1. A sandbox merchant in dashboard.505pay.link (sign-up + KYB-lite — sandbox merchants are auto-approved).
  2. A sandbox API key with the payments.payment_intent.create scope. Create one at Settings → API Keys → Create. The value is shown ONCE — store it as PAYOPS_SECRET_KEY (prefix sk_test_…).
  3. Your merchant's UUID. Find it at Settings → General or via GET /v1/payments/merchants/me. Store as PAYOPS_MERCHANT_ID.

Why an API key, not an OAuth token?

Server-to-server merchant integrations authenticate with a long-lived secret API key (sk_test_… / sk_live_…). OAuth client_credentials is for the dashboard / console flows, not for your checkout backend. See API keys guide for rotation and scoping.


Create a payment intent

The single mandatory request for QRIS Dynamic — the response contains everything you need to display the QR or redirect to the hosted page.

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-1234-$(uuidgen)" \
  -d '{
    "merchant_id": "'"$PAYOPS_MERCHANT_ID"'",
    "amount_idr": 50000,
    "max_uses": 1,
    "description": "Order #1234",
    "allowed_channels": ["qris"],
    "metadata": { "order_id": "1234" }
  }'
const res = await fetch('https://sandbox.505pay.link/v1/payments/intents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.PAYOPS_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `order-1234-${crypto.randomUUID()}`,
  },
  body: JSON.stringify({
    merchant_id: process.env.PAYOPS_MERCHANT_ID,
    amount_idr: 50_000,
    max_uses: 1,
    description: 'Order #1234',
    allowed_channels: ['qris'],
    metadata: { order_id: '1234' },
  }),
});
const intent = await res.json();
// intent.checkout_url, intent.shortcode, intent.id
import os, uuid, requests

intent = requests.post(
    'https://sandbox.505pay.link/v1/payments/intents',
    headers={
        'Authorization': f"Bearer {os.environ['PAYOPS_SECRET_KEY']}",
        'Idempotency-Key': f'order-1234-{uuid.uuid4()}',
    },
    json={
        'merchant_id': os.environ['PAYOPS_MERCHANT_ID'],
        'amount_idr': 50_000,
        'max_uses': 1,
        'description': 'Order #1234',
        'allowed_channels': ['qris'],
        'metadata': {'order_id': '1234'},
    },
    timeout=10,
).json()
$res = (new \GuzzleHttp\Client())->post(
    'https://sandbox.505pay.link/v1/payments/intents',
    [
        'headers' => [
            'Authorization' => 'Bearer ' . getenv('PAYOPS_SECRET_KEY'),
            'Idempotency-Key' => 'order-1234-' . \Ramsey\Uuid\Uuid::uuid4(),
        ],
        'json' => [
            'merchant_id' => getenv('PAYOPS_MERCHANT_ID'),
            'amount_idr' => 50000,
            'max_uses' => 1,
            'description' => 'Order #1234',
            'allowed_channels' => ['qris'],
            'metadata' => ['order_id' => '1234'],
        ],
    ]
);
$intent = json_decode($res->getBody(), true);
body, _ := json.Marshal(map[string]any{
    "merchant_id":      os.Getenv("PAYOPS_MERCHANT_ID"),
    "amount_idr":       50_000,
    "max_uses":         1,
    "description":      "Order #1234",
    "allowed_channels": []string{"qris"},
    "metadata":         map[string]string{"order_id": "1234"},
})
req, _ := http.NewRequest("POST",
    "https://sandbox.505pay.link/v1/payments/intents",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("PAYOPS_SECRET_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "order-1234-"+uuid.New().String())
resp, _ := http.DefaultClient.Do(req)

Response (201 Created):

{
  "id": "018f7c2d-1f8b-7c2d-9a2d-1f8b92f6d101",
  "shortcode": "abc123def456",
  "merchant_id": "018f4d9f-7b8a-7c2d-9a2d-1f8b92f6d101",
  "amount_idr": 50000,
  "status": "open",
  "allowed_channels": ["qris"],
  "description": "Order #1234",
  "checkout_url": "https://sandbox.505pay.link/pay/abc123def456",
  "metadata": { "order_id": "1234" },
  "created_at": "2026-05-17T12:00:00Z"
}

Show the payer the checkout

Two options, depending on whether you want 505pay to host the UI:

  • Hosted (recommended): redirect the browser to intent.checkout_url. 505pay renders the QR, polls for completion, and handles UX edge cases.
  • Self-hosted: call GET /v1/public/payments/intents/{shortcode} (unauthenticated, intended for your own frontend) and render the returned channel-specific qr_string as a QR image.

In sandbox you can simulate payment completion from the dashboard's Payments → Intents → simulate button — no scan required.

Receive the payment.completed webhook

Subscribe to webhooks at Settings → Webhooks → New. Set url = https://your-server.example/webhooks/505pay and copy the displayed secret into your env (PAYOPS_WEBHOOK_SECRET).

When the payment completes 505pay POSTs the event to your endpoint:

POST /webhooks/505pay HTTP/1.1
Content-Type: application/json
X-Straventa-Signature: 4f1c…hex
X-Straventa-Event-Id: 018f8a…
X-Straventa-Delivery-Id: 018f8a…
X-Straventa-Event-Type: payment.completed

{
  "event": "payment.completed",
  "intent_id": "018f7c2d-1f8b-7c2d-9a2d-1f8b92f6d101",
  "merchant_id": "018f4d9f-…",
  "shortcode": "abc123def456",
  "amount_idr": 50000,
  "status": "paid",
  "completed_at": "2026-05-17T12:01:23Z",
  "metadata": { "order_id": "1234" }
}

Verify with HMAC-SHA256 over the raw request body using PAYOPS_WEBHOOK_SECRET. See Webhooks guide for the per-language recipe.

Fulfil on the webhook, not the response

The 201 response confirms the intent was created. The payment.completed webhook confirms funds were captured. Always fulfil on the webhook.

Confirm end-to-end

You can poll the intent at any time:

curl https://sandbox.505pay.link/v1/payments/intents/$INTENT_ID \
  -H "Authorization: Bearer $PAYOPS_SECRET_KEY"
# → { "status": "paid", ... }

Next steps

Was this page helpful?

On this page