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