Examples

Complete, copy-paste integrations. Replace https://pay.cartino.net with the Cartino Pay base URL and put the token / webhook secret in environment variables.


cURL

export CP_URL="https://pay.cartino.net"
export CP_TOKEN="cp_test_xxxxxxxxxxxxxxxxxxxxxxxx"

# 1. Who am I?
curl -s "$CP_URL/api/v1/me" -H "Authorization: Bearer $CP_TOKEN" | jq

# 2. Create a payment (idempotent)
curl -s -X POST "$CP_URL/api/v1/payments" \
  -H "Authorization: Bearer $CP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-CLOUD-12345" \
  -d '{"amount":500000,"order_id":"CLOUD-12345","description":"Cartino Cloud subscription","callback_url":"https://cartino.cloud/payment/callback","metadata":{"user_id":123}}' | jq

# 3. Status
curl -s "$CP_URL/api/v1/payments/PAY_01J9X4Z8K3M2N5P7Q9R1S3T5V7" -H "Authorization: Bearer $CP_TOKEN" | jq .data.status

# 4. Force verification
curl -s -X POST "$CP_URL/api/v1/payments/PAY_01J9X4Z8K3M2N5P7Q9R1S3T5V7/verify" -H "Authorization: Bearer $CP_TOKEN" | jq .data.status

PHP / Laravel

config/services.php

'cartino_pay' => [
    'url'            => env('CARTINO_PAY_URL', 'https://pay.cartino.net'),
    'token'          => env('CARTINO_PAY_TOKEN'),
    'webhook_secret' => env('CARTINO_PAY_WEBHOOK_SECRET'),
],

app/Services/CartinoPay.php

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use RuntimeException;

class CartinoPay
{
    public function __construct(
        private readonly string $baseUrl = '',
        private readonly string $token = '',
    ) {}

    public static function make(): self
    {
        return new self(config('services.cartino_pay.url'), config('services.cartino_pay.token'));
    }

    /** @return array<string,mixed> payment object */
    public function createPayment(array $payload, string $idempotencyKey): array
    {
        return $this->call('post', '/api/v1/payments', $payload, ['Idempotency-Key' => $idempotencyKey]);
    }

    public function getPayment(string $paymentId): array
    {
        return $this->call('get', "/api/v1/payments/{$paymentId}");
    }

    public function verifyPayment(string $paymentId): array
    {
        return $this->call('post', "/api/v1/payments/{$paymentId}/verify");
    }

    public static function verifyWebhookSignature(string $secret, string $rawBody, string $header, int $tolerance = 300): bool
    {
        $timestamp = null; $signatures = [];
        foreach (explode(',', $header) as $part) {
            [$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
            if ($k === 't' && ctype_digit((string) $v)) $timestamp = (int) $v;
            if ($k === 'v1' && $v !== null)             $signatures[] = $v;
        }
        if ($timestamp === null || $signatures === [] || abs(time() - $timestamp) > $tolerance) return false;
        $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
        foreach ($signatures as $sig) if (hash_equals($expected, $sig)) return true;
        return false;
    }

    private function call(string $method, string $path, array $body = [], array $headers = []): array
    {
        $response = Http::withToken($this->token)
            ->withHeaders($headers)
            ->acceptJson()
            ->timeout(20)
            ->retry(2, 500, fn ($e) => $e instanceof \Illuminate\Http\Client\ConnectionException)
            ->{$method}($this->baseUrl.$path, $body);

        $json = $response->json() ?? [];

        if (! ($json['success'] ?? false)) {
            $code = $json['error']['code'] ?? 'INTERNAL_ERROR';
            throw new RuntimeException("Cartino Pay error {$code}: ".($json['error']['message'] ?? $response->status()));
        }

        return $json['data'];
    }
}

Checkout controller

public function pay(Order $order, CartinoPay $pay)
{
    $order->increment('payment_attempts');

    $payment = $pay->createPayment([
        'amount'       => $order->total_irr,
        'order_id'     => (string) $order->id,
        'description'  => "Order #{$order->id}",
        'callback_url' => route('checkout.callback'),
        'metadata'     => ['user_id' => $order->user_id],
    ], idempotencyKey: "order-{$order->id}-attempt-{$order->payment_attempts}");

    $order->update(['cartino_payment_id' => $payment['payment_id']]);

    return redirect()->away($payment['payment_url']);
}

public function callback(Request $request, CartinoPay $pay)
{
    $order = Order::where('cartino_payment_id', $request->query('payment_id'))->firstOrFail();
    $payment = $pay->getPayment($order->cartino_payment_id); // never trust ?status=

    return view('checkout.result', ['order' => $order, 'payment' => $payment]);
}

Webhook controller

public function webhook(Request $request)
{
    if (! CartinoPay::verifyWebhookSignature(
        config('services.cartino_pay.webhook_secret'),
        $request->getContent(),
        $request->header('X-CartinoPay-Signature', ''),
    )) {
        return response('invalid signature', 400);
    }

    $event = $request->json()->all();

    // Idempotency: retries reuse event_id.
    if (! Cache::add("cp-webhook:{$event['event_id']}", 1, now()->addDays(7))) {
        return response('', 200);
    }

    $order = Order::where('cartino_payment_id', $event['payment_id'])->first();
    if (! $order) {
        return response('', 200); // unknown to us; acknowledge to stop retries
    }

    if ($event['event'] === 'payment.paid' && $event['amount'] === $order->total_irr && $event['currency'] === 'IRR') {
        $order->markPaid(trackingCode: $event['tracking_code']);
    } elseif (in_array($event['event'], ['payment.failed', 'payment.cancelled', 'payment.expired'])) {
        $order->markUnpaid($event['status']);
    }

    return response('', 200);
}

Exclude the webhook route from CSRF in bootstrap/app.php:

$middleware->validateCsrfTokens(except: ['payments/webhook']);

JavaScript / Node.js (Express)

import express from 'express';
import crypto from 'node:crypto';

const CP_URL = process.env.CARTINO_PAY_URL ?? 'https://pay.cartino.net';
const CP_TOKEN = process.env.CARTINO_PAY_TOKEN;
const CP_WEBHOOK_SECRET = process.env.CARTINO_PAY_WEBHOOK_SECRET;

async function cp(method, path, body, headers = {}) {
  const res = await fetch(CP_URL + path, {
    method,
    headers: { Authorization: `Bearer ${CP_TOKEN}`, 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (!json.success) throw new Error(`Cartino Pay ${json.error.code}: ${json.error.message}`);
  return json.data;
}

export const createPayment = (payload, idempotencyKey) => cp('POST', '/api/v1/payments', payload, { 'Idempotency-Key': idempotencyKey });
export const getPayment = (id) => cp('GET', `/api/v1/payments/${id}`);
export const verifyPayment = (id) => cp('POST', `/api/v1/payments/${id}/verify`);

export function verifyWebhookSignature(secret, rawBody, header, tolerance = 300) {
  let timestamp = null; const signatures = [];
  for (const part of header.split(',')) {
    const [k, v] = part.trim().split('=');
    if (k === 't' && /^\d+$/.test(v)) timestamp = Number(v);
    if (k === 'v1' && v) signatures.push(v);
  }
  if (timestamp === null || !signatures.length) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

const app = express();

// Start checkout
app.post('/checkout/:orderId/pay', express.json(), async (req, res) => {
  const order = await orders.find(req.params.orderId);
  const attempt = await orders.incrementAttempts(order.id);

  const payment = await createPayment({
    amount: order.totalIrr,
    order_id: String(order.id),
    description: `Order #${order.id}`,
    callback_url: 'https://cartino.cloud/payment/callback',
    metadata: { user_id: order.userId },
  }, `order-${order.id}-attempt-${attempt}`);

  await orders.setPaymentId(order.id, payment.payment_id);
  res.redirect(302, payment.payment_url);
});

// Payer returns — show result, never trust ?status=
app.get('/payment/callback', async (req, res) => {
  const payment = await getPayment(String(req.query.payment_id));
  res.render('checkout/result', { payment });
});

// Webhook — raw body is required for signature verification
app.post('/payments/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyWebhookSignature(CP_WEBHOOK_SECRET, req.body, req.get('X-CartinoPay-Signature') ?? '')) {
    return res.status(400).send('invalid signature');
  }
  const event = JSON.parse(req.body.toString('utf8'));

  if (await webhookEvents.seen(event.event_id)) return res.sendStatus(200);
  await webhookEvents.remember(event.event_id);

  const order = await orders.findByPaymentId(event.payment_id);
  if (order && event.event === 'payment.paid' && event.amount === order.totalIrr) {
    await orders.markPaid(order.id, event.tracking_code);
  }
  res.sendStatus(200);
});

app.listen(3000);

Reconciliation job (any language)

every 5 minutes:
  for order in orders where cartino_status in ('pending','processing') and created_at < now() - 5 min:
      payment = POST /api/v1/payments/{order.cartino_payment_id}/verify
      update order from payment.status