# sPay API — Skill for AI Agents

> **Version:** 1.0  
> **Base URL:** `http://localhost:8000` (development) | `https://backend-phi-seven-54.vercel.app` (production)  
> **Auth:** `X-API-Key: <your_api_key>` header for merchant endpoints.  
> **Content-Type:** `application/json` for all requests.

---

## Overview

sPay is an Angolan payment gateway that validates bank transfer receipts (PDF comprovatives) sent by email. The flow is:

1. **Merchant** creates a payment charge via API → gets a `payment_id` and a unique `email` alias.
2. **Customer** makes a bank transfer (BAI Directo, EMIS/Multicaixa Express, etc.) and emails the PDF comprovative to the returned `email`.
3. **sPay** parses the PDF, validates the recipient IBAN/phone, amount and uniqueness of the transaction.
4. **sPay** fires a signed webhook to the merchant's configured endpoint.
5. **Merchant** receives `payment.paid` event and fulfils the order.

---

## Authentication

All merchant API calls require the `X-API-Key` header.

```
X-API-Key: spk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Public endpoints (e.g. `GET /api/payments/{id}`) do **not** require authentication.

---

## Endpoints

### 1. Create Payment

**Creates a new payment charge.**

```
POST /api/payments
X-API-Key: <api_key>
Content-Type: application/json
```

**Request Body:**
```json
{
  "amount": 3000,
  "expires_in_minutes": 15,
  "description": "Compra #1234 - Produto XYZ"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `amount` | `number` | ✅ | Amount in Kz (Kwanzas). Must be > 0. |
| `expires_in_minutes` | `integer` | ✅ | Expiry time. Range: 5–1440 (1 day). |
| `description` | `string` | ❌ | Optional order description shown on checkout page. |

**Response `201`:**
```json
{
  "payment_id": "pay_x8f92k",
  "email": "pay-pay-x8f92k@domain.mailtm.com",
  "expires_at": "2026-06-16T21:15:00.000Z",
  "pay_url": "https://spayment.vercel.app/pay/pay_x8f92k",
  "amount": 3000,
  "currency": "Kz",
  "status": "pending"
}
```

| Field | Description |
|-------|-------------|
| `payment_id` | Unique identifier. Store this to track the payment. |
| `email` | Temporary email alias. The customer must send the PDF to this address. |
| `expires_at` | ISO 8601 datetime after which the payment is considered expired. |
| `pay_url` | Public URL to the hosted payment page to redirect the customer. |

---

### 2. Get Payment Status

**Checks the current status of a payment. No auth required.**

```
GET /api/payments/{payment_id}
```

**Response `200`:**
```json
{
  "payment_id": "pay_x8f92k",
  "status": "pending",
  "amount": 3000.0,
  "currency": "Kz",
  "description": "Compra #1234",
  "created_at": "2026-06-16T21:00:00",
  "expires_at": "2026-06-16T21:15:00",
  "paid_at": null,
  "transaction_id": null
}
```

**Status values:**

| Value | Description |
|-------|-------------|
| `pending` | Awaiting customer payment. |
| `paid` | Payment confirmed. Receipt validated. |
| `expired` | Expiry time passed without confirmation. |

---

### 3. List Payments

**Lists all payments for the authenticated merchant.**

```
GET /api/payments
X-API-Key: <api_key>
```

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `limit` | `integer` | `50` | Max results to return. |
| `offset` | `integer` | `0` | Pagination offset. |
| `status` | `string` | (all) | Filter by status: `pending`, `paid`, `expired`. |

**Response `200`:**
```json
[
  {
    "payment_id": "pay_x8f92k",
    "status": "paid",
    "amount": 3000.0,
    "currency": "Kz",
    "created_at": "2026-06-16T21:00:00",
    "paid_at": "2026-06-16T21:08:42",
    "transaction_id": "10963242"
  }
]
```

---

### 4. Configure Webhook

**Sets the URL that sPay will POST to when a payment is confirmed.**

```
POST /api/webhook/config
Authorization: Bearer <jwt_token>
Content-Type: application/json
```

**Request Body:**
```json
{
  "url": "https://your-server.com/webhooks/spay"
}
```

**Response `200`:**
```json
{
  "url": "https://your-server.com/webhooks/spay",
  "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```

> ⚠️ Save the `secret` — it's used to verify webhook signatures.

---

### 5. Test Webhook

**Sends a test `payment.paid` event to your configured webhook URL.**

```
POST /api/webhook/test
Authorization: Bearer <jwt_token>
```

**Response `200`:**
```json
{ "success": true }
```

---

## Webhook Events

When a payment is confirmed, sPay sends an HTTP POST to your webhook URL.

**Headers:**
```
Content-Type: application/json
X-SPay-Signature: <hmac_sha256_hex>
```

**Payload:**
```json
{
  "event": "payment.paid",
  "data": {
    "payment_id": "pay_x8f92k",
    "status": "paid",
    "amount": 3000.0,
    "currency": "Kz",
    "transaction_id": "10963242",
    "recipient_phone": "922599463",
    "paid_at": "2026-06-07T12:04:05"
  }
}
```

### Signature Verification

```python
import hmac, hashlib, json

def verify_spay_signature(payload_dict: dict, signature: str, secret: str) -> bool:
    payload_str = json.dumps(payload_dict, sort_keys=True)
    computed = hmac.new(
        secret.encode("utf-8"),
        payload_str.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(computed, signature)
```

```javascript
const crypto = require("crypto");

function verifySpaySignature(payloadObj, signature, secret) {
  const payloadStr = JSON.stringify(payloadObj, Object.keys(payloadObj).sort());
  const computed = crypto.createHmac("sha256", secret).update(payloadStr).digest("hex");
  return computed === signature;
}
```

---

## Error Responses

All errors return JSON:

```json
{
  "detail": "Human-readable error description in Portuguese."
}
```

| HTTP Code | When it happens |
|-----------|----------------|
| `400` | Malformed request body (missing/invalid fields). |
| `401` | Missing or invalid `X-API-Key`. |
| `403` | Insufficient billing balance, or plan limit reached (e.g. bank accounts). |
| `404` | `payment_id` not found. |
| `409` | Duplicate transaction: the same PDF was already used to confirm another payment. |
| `422` | Validation error (e.g. `amount <= 0`, `expires_in_minutes` out of range 5–1440). |
| `500` | Internal server error. |

---

## Plan Limits

sPay operates on a pre-paid credit model. Each confirmed payment deducts **25 Kz** from billing balance.

| Plan | Price | Validations | Bank Accounts |
|------|-------|-------------|---------------|
| Starter | 1.000 Kz | 40 | 1 |
| Boost | 5.000 Kz | 210 | 2 |
| Growth | 10.000 Kz | 450 | 4 |
| Scale | 25.000 Kz | 1.200 | 10 |

---

## Full Integration Example (Python)

```python
import requests
import hmac
import hashlib
import json
from flask import Flask, request, jsonify

API_KEY = "spk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE_URL = "http://localhost:8000"
WEBHOOK_SECRET = "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

app = Flask(__name__)

# 1. Create a payment charge
def create_payment(amount: float, expires_in_minutes: int = 15, description: str = ""):
    response = requests.post(
        f"{BASE_URL}/api/payments",
        headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
        json={"amount": amount, "expires_in_minutes": expires_in_minutes, "description": description}
    )
    response.raise_for_status()
    return response.json()

# 2. Poll payment status (or use webhooks instead)
def get_payment_status(payment_id: str):
    response = requests.get(f"{BASE_URL}/api/payments/{payment_id}")
    response.raise_for_status()
    return response.json()

# 3. Receive and verify webhook
@app.post("/webhooks/spay")
def spay_webhook():
    signature = request.headers.get("X-SPay-Signature", "")
    payload = request.get_json()

    # Verify authenticity
    payload_str = json.dumps(payload, sort_keys=True)
    computed = hmac.new(
        WEBHOOK_SECRET.encode(), payload_str.encode(), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(computed, signature):
        return jsonify({"error": "Invalid signature"}), 401

    # Process the event
    if payload["event"] == "payment.paid":
        payment_id = payload["data"]["payment_id"]
        amount = payload["data"]["amount"]
        print(f"Payment {payment_id} of {amount} Kz confirmed!")
        # → Fulfil the order here

    return jsonify({"ok": True}), 200

# Usage
if __name__ == "__main__":
    charge = create_payment(amount=3500, expires_in_minutes=10, description="Order #42")
    print(f"Redirect customer to: {charge['pay_url']}")
    print(f"Payment ID to track: {charge['payment_id']}")
```

---

## Full Integration Example (Node.js)

```javascript
const express = require("express");
const crypto = require("crypto");

const API_KEY = "spk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const BASE_URL = "http://localhost:8000";
const WEBHOOK_SECRET = "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";

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

// 1. Create a payment charge
async function createPayment(amount, expiresInMinutes = 15, description = "") {
  const res = await fetch(`${BASE_URL}/api/payments`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-Key": API_KEY },
    body: JSON.stringify({ amount, expires_in_minutes: expiresInMinutes, description }),
  });
  if (!res.ok) throw new Error(`sPay error: ${await res.text()}`);
  return res.json();
}

// 2. Poll payment status
async function getPaymentStatus(paymentId) {
  const res = await fetch(`${BASE_URL}/api/payments/${paymentId}`);
  if (!res.ok) throw new Error("Payment not found");
  return res.json();
}

// 3. Receive and verify webhook
app.post("/webhooks/spay", (req, res) => {
  const signature = req.headers["x-spay-signature"];
  const payloadStr = JSON.stringify(req.body, Object.keys(req.body).sort());
  const computed = crypto.createHmac("sha256", WEBHOOK_SECRET).update(payloadStr).digest("hex");

  if (computed !== signature) return res.status(401).send("Invalid signature");

  const { event, data } = req.body;
  if (event === "payment.paid") {
    console.log(`Payment ${data.payment_id} of ${data.amount} Kz confirmed!`);
    // → Fulfil the order here
  }

  res.status(200).send("OK");
});

// Usage
(async () => {
  const charge = await createPayment(3500, 10, "Order #42");
  console.log("Redirect customer to:", charge.pay_url);
  console.log("Payment ID:", charge.payment_id);
})();

app.listen(3001);
```

---

## AI Agent Instructions

If you are an AI agent using this API, follow this decision tree:

```
TASK: Process a payment of X Kz for order Y
│
├─ STEP 1: POST /api/payments
│   Body: { amount: X, expires_in_minutes: 15, description: "Order Y" }
│   → Store: payment_id, pay_url
│
├─ STEP 2: Present pay_url to user (or redirect)
│   Tell user: "Send the PDF comprovative to the email shown on that page."
│
├─ STEP 3: Poll GET /api/payments/{payment_id} every 30s
│   → If status == "paid" → proceed to fulfil order
│   → If status == "expired" → notify user, create new payment if needed
│   → If status == "pending" → continue polling
│
└─ STEP 4 (preferred over polling): Listen on POST /webhooks/spay
    → Verify X-SPay-Signature header
    → On event "payment.paid" → fulfil order immediately
```

### Minimal Agent Prompt Template

```
You are a payment assistant integrated with sPay.
- API Key: {API_KEY}
- Base URL: {BASE_URL}
- Webhook Secret: {WEBHOOK_SECRET}

When asked to charge a customer:
1. Call POST /api/payments with the amount and a 15-minute expiry.
2. Return the pay_url to the user.
3. Track payment_id and report status when queried.
4. On webhook event "payment.paid", confirm the transaction.
```

### OpenAPI-style JSON Schema for Tools

```json
{
  "tools": [
    {
      "name": "create_payment",
      "description": "Creates a new sPay payment charge. Returns a payment_id and pay_url.",
      "parameters": {
        "type": "object",
        "properties": {
          "amount": { "type": "number", "description": "Amount in Kz. Must be > 0." },
          "expires_in_minutes": { "type": "integer", "description": "Expiry in minutes. Range 5-1440.", "default": 15 },
          "description": { "type": "string", "description": "Optional order description." }
        },
        "required": ["amount", "expires_in_minutes"]
      }
    },
    {
      "name": "get_payment_status",
      "description": "Checks the current status of a payment (pending/paid/expired).",
      "parameters": {
        "type": "object",
        "properties": {
          "payment_id": { "type": "string", "description": "The payment_id returned by create_payment." }
        },
        "required": ["payment_id"]
      }
    },
    {
      "name": "list_payments",
      "description": "Lists all payments for the authenticated merchant with optional filters.",
      "parameters": {
        "type": "object",
        "properties": {
          "status": { "type": "string", "enum": ["pending", "paid", "expired"], "description": "Filter by status." },
          "limit": { "type": "integer", "default": 50 },
          "offset": { "type": "integer", "default": 0 }
        }
      }
    }
  ]
}
```

---

## Supported Payment Sources

| Source | Receipt Type | Sender Email | Matching Method |
|--------|-------------|--------------|-----------------|
| BAI Directo (by phone) | PDF email | `baidirecto@bancobai.ao` | Phone number (last 9 digits) |
| BAI Directo (by IBAN) | PDF email | `baidirecto@bancobai.ao` | Full IBAN exact match |
| EMIS / Multicaixa Express | PDF email | `noreply@emis.co.ao` | Phone number (last 9 digits) or full IBAN exact match |

> **Security:** BAI Directo receipts from any sender other than `baidirecto@bancobai.ao` and EMIS/Multicaixa Express receipts from any sender other than `noreply@emis.co.ao` are **rejected as fraudulent**.

---

## Quick Reference Card

```bash
# Create payment
curl -X POST "BASE_URL/api/payments" \
  -H "X-API-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": 3000, "expires_in_minutes": 15}'

# Check status (no auth)
curl "BASE_URL/api/payments/PAYMENT_ID"

# List payments
curl "BASE_URL/api/payments" \
  -H "X-API-Key: YOUR_KEY"
```
