Pointy ERP API

For developers connecting a shop's ERP or POS software to Pointy, so cashback points are added automatically with each invoice. The same text is published at https://point-y.com/developers.html.

Before you start

Basics

Errors

{ "error": "<reason>", "message": "<readable text>", "context": { ... } } with an HTTP status. Validation errors are 422 with errors per field.

StatuserrorMeaning
401api_key_invalidWrong or revoked key
402api_not_subscribedThe branch's ERP link is not paid or has ended
403merchant_inactive, subscription_expired, not_allowedThe shop or branch cannot add points now
409invoice_already_earnedPoints were already added for this invoice number (context.reference)
422invoice_below_minimum, exceeds_cashback_per_earn, merchant_credit_limit, customer_blocked, invalid_phoneThe sale gets no points; show the message to the cashier
422shortfall_settlement_requiredA return needs a decision (see below)

Endpoints

GET /me

Checks the key. Returns the shop, the branch, the key's name and prefix, subscription_ends_at, and point_value_piasters (10 = 0.10 EGP per point).

GET /customers/{phone or code}

For printing on the receipt: { "registered": true, "first_name": "منى", "points_here": 240 }. points_here is what the customer can spend at this branch.

POST /earns (Idempotency-Key)

Adds points for an invoice.

{ "customer": "01012345678", "invoice_amount_piasters": 45000, "invoice_number": "INV-1001", "whatsapp_consent": false }

201 with the earn:

{ "data": { "reference": "01JB...", "status": "completed", "points": 225, "cashback_piasters": 2250,
  "invoice_amount_piasters": 45000, "invoice_number": "INV-1001", "source": "erp",
  "customer": { "name": "منى", "phone": "+20101****678", "registered": true }, "returned_points": 0, "created_at": "..." } }

status is pending_approval when the amount is above the approval limit the owner set for this key; a manager approves it in the app, and the points are added then. An invoice number gets points once.

GET /earns/{reference} and GET /earns?invoice_number=INV-1001

Read back what this key added.

POST /earns/{reference}/returns/preview

Optional { "invoice_amount_piasters": 20000 } (a partial return; omit it for the whole invoice). Returns the points that would come back, and shortfall_piasters when the customer already spent some of them.

POST /earns/{reference}/returns (Idempotency-Key)

{ "invoice_amount_piasters": 20000, "reason": "Returned 1 item" }

Without invoice_amount_piasters the whole invoice is cancelled. Points come back in proportion to the returned amount. A key can return only the invoices it added, on any day.

If the customer already spent points from this invoice, the answer is 422 shortfall_settlement_required with the amount; send the request again with "shortfall_settlement": "customer_paid" (the cashier collected the value from the customer) or "merchant_absorbed" (the shop takes the loss).

Example (curl)

curl -X POST https://api.point-y.com/api/v1/erp/earns \
  -H "Authorization: Bearer pty_..." -H "Idempotency-Key: 7f1c2a9e-inv-1001" \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"customer":"01012345678","invoice_amount_piasters":45000,"invoice_number":"INV-1001"}'

Code examples

Each example does the same three things: looks a customer up, adds points for an invoice (retrying safely on timeouts), and returns part of an invoice. Each call makes one Idempotency-Key and reuses it for its own retries, so a timeout never adds points twice; a refusal is kept for that key for 24 hours, so a later attempt (after the shop paid, say) needs a new key, and the invoice number still stops the same invoice getting points twice (409 invoice_already_earned, which the examples treat as done). Read the API key from your configuration (here the POINTY_API_KEY environment variable), never from the code.

PHP (7.4+, curl)

<?php
const POINTY_URL = 'https://api.point-y.com/api/v1/erp';

function pointy(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
{
    $headers = ['Authorization: Bearer ' . getenv('POINTY_API_KEY'), 'Accept: application/json', 'Accept-Language: ar'];
    if ($body !== null) $headers[] = 'Content-Type: application/json';
    if ($idempotencyKey !== null) $headers[] = 'Idempotency-Key: ' . $idempotencyKey;

    for ($attempt = 1; ; $attempt++) {
        $ch = curl_init(POINTY_URL . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 15,
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        // A timeout, 429 or 5xx: try again with the same key (it is safe).
        if (($raw === false || $status === 429 || $status >= 500) && $attempt < 3) {
            sleep(2 * $attempt);
            continue;
        }
        $json = json_decode((string) $raw, true) ?? [];
        return ['status' => $status, 'json' => $json];
    }
}

// Add points for a final invoice. Returns the earn, or throws with a message to show the cashier.
function pointyEarn(string $customer, int $amountPiasters, string $invoiceNumber): array
{
    $r = pointy('POST', '/earns', [
        'customer' => $customer,
        'invoice_amount_piasters' => $amountPiasters,
        'invoice_number' => $invoiceNumber,
    ], 'inv-' . $invoiceNumber . '-' . bin2hex(random_bytes(6)));

    if ($r['status'] === 201) return $r['json']['data'];
    if (($r['json']['error'] ?? '') === 'invoice_already_earned') {
        return pointy('GET', '/earns?invoice_number=' . urlencode($invoiceNumber))['json']['data'][0];
    }
    throw new RuntimeException($r['json']['message'] ?? "Pointy error {$r['status']}");
}

// Return part of an invoice (or all of it with $amountPiasters = null).
function pointyReturn(string $reference, ?int $amountPiasters, string $reason, string $returnId): array
{
    $body = array_filter(['invoice_amount_piasters' => $amountPiasters, 'reason' => $reason], fn ($v) => $v !== null);
    $r = pointy('POST', "/earns/{$reference}/returns", $body, 'ret-' . $returnId);
    if ($r['status'] < 300) return $r['json'];
    // shortfall_settlement_required: ask the cashier, then send again with
    // 'shortfall_settlement' => 'customer_paid' or 'merchant_absorbed'.
    throw new RuntimeException($r['json']['message'] ?? "Pointy error {$r['status']}");
}

$customer = pointy('GET', '/customers/' . urlencode('01012345678'))['json'];
$earn = pointyEarn('01012345678', 45000, 'INV-1001');
echo "Points added: {$earn['points']}\n";

C# (.NET 6+)

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

public class PointyClient
{
    private readonly HttpClient _http;

    public PointyClient(string apiKey)
    {
        _http = new HttpClient { BaseAddress = new Uri("https://api.point-y.com/api/v1/erp/"), Timeout = TimeSpan.FromSeconds(15) };
        _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
        _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        _http.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("ar"));
    }

    private async Task<(HttpStatusCode Status, JsonElement Json)> SendAsync(HttpMethod method, string path, object? body = null, string? idempotencyKey = null)
    {
        for (var attempt = 1; ; attempt++)
        {
            using var request = new HttpRequestMessage(method, path);
            if (body != null) request.Content = JsonContent.Create(body);
            if (idempotencyKey != null) request.Headers.Add("Idempotency-Key", idempotencyKey);
            try
            {
                using var response = await _http.SendAsync(request);
                var status = response.StatusCode;
                if ((status == HttpStatusCode.TooManyRequests || (int)status >= 500) && attempt < 3)
                {
                    await Task.Delay(2000 * attempt);
                    continue;
                }
                var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone();
                return (status, json);
            }
            catch (Exception e) when ((e is HttpRequestException || e is TaskCanceledException) && attempt < 3)
            {
                // A timeout: try again with the same key (it is safe).
                await Task.Delay(2000 * attempt);
            }
        }
    }

    // Adds points for a final invoice. Throws with a message to show the cashier.
    public async Task<JsonElement> EarnAsync(string customer, long amountPiasters, string invoiceNumber)
    {
        var (status, json) = await SendAsync(HttpMethod.Post, "earns",
            new { customer, invoice_amount_piasters = amountPiasters, invoice_number = invoiceNumber }, $"inv-{invoiceNumber}-{Guid.NewGuid():N}");

        if (status == HttpStatusCode.Created) return json.GetProperty("data");
        if (json.TryGetProperty("error", out var error) && error.GetString() == "invoice_already_earned")
        {
            var (_, found) = await SendAsync(HttpMethod.Get, $"earns?invoice_number={Uri.EscapeDataString(invoiceNumber)}");
            return found.GetProperty("data")[0];
        }
        throw new InvalidOperationException(json.TryGetProperty("message", out var m) ? m.GetString() : $"Pointy error {(int)status}");
    }

    // Returns part of an invoice (or all of it with amountPiasters = null).
    public async Task<JsonElement> ReturnAsync(string reference, long? amountPiasters, string reason, string returnId)
    {
        object body = amountPiasters is null ? new { reason } : new { invoice_amount_piasters = amountPiasters, reason };
        var (status, json) = await SendAsync(HttpMethod.Post, $"earns/{reference}/returns", body, $"ret-{returnId}");
        if ((int)status < 300) return json;
        // shortfall_settlement_required: ask the cashier, then send again with
        // shortfall_settlement = "customer_paid" or "merchant_absorbed".
        throw new InvalidOperationException(json.TryGetProperty("message", out var m) ? m.GetString() : $"Pointy error {(int)status}");
    }
}

// var pointy = new PointyClient(Environment.GetEnvironmentVariable("POINTY_API_KEY")!);
// var earn = await pointy.EarnAsync("01012345678", 45000, "INV-1001");
// Console.WriteLine($"Points added: {earn.GetProperty("points")}");

Python (3.8+, requests)

import os
import time
import uuid

import requests

POINTY_URL = "https://api.point-y.com/api/v1/erp"
session = requests.Session()
session.headers.update({
    "Authorization": f"Bearer {os.environ['POINTY_API_KEY']}",
    "Accept": "application/json",
    "Accept-Language": "ar",
})


class PointyError(Exception):
    def __init__(self, status, body):
        self.status, self.error = status, body.get("error")
        super().__init__(body.get("message", f"Pointy error {status}"))


def pointy(method, path, body=None, idempotency_key=None):
    headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
    for attempt in range(1, 4):
        try:
            r = session.request(method, POINTY_URL + path, json=body, headers=headers, timeout=15)
        except requests.RequestException:
            if attempt == 3:
                raise
            time.sleep(2 * attempt)  # a timeout: try again with the same key (it is safe)
            continue
        if (r.status_code == 429 or r.status_code >= 500) and attempt < 3:
            time.sleep(2 * attempt)
            continue
        data = r.json() if r.content else {}
        if r.status_code >= 400:
            raise PointyError(r.status_code, data)
        return data


def earn(customer, amount_piasters, invoice_number):
    """Adds points for a final invoice. Raises PointyError with a message for the cashier."""
    try:
        return pointy("POST", "/earns", {
            "customer": customer,
            "invoice_amount_piasters": amount_piasters,
            "invoice_number": invoice_number,
        }, f"inv-{invoice_number}-{uuid.uuid4().hex}")["data"]
    except PointyError as e:
        if e.error == "invoice_already_earned":
            return pointy("GET", f"/earns?invoice_number={requests.utils.quote(invoice_number)}")["data"][0]
        raise


def return_invoice(reference, amount_piasters, reason, return_id, shortfall_settlement=None):
    """Returns part of an invoice (or all of it with amount_piasters=None).
    On error 'shortfall_settlement_required', ask the cashier and call again with
    shortfall_settlement='customer_paid' or 'merchant_absorbed'."""
    body = {"reason": reason}
    if amount_piasters is not None:
        body["invoice_amount_piasters"] = amount_piasters
    if shortfall_settlement:
        body["shortfall_settlement"] = shortfall_settlement
    return pointy("POST", f"/earns/{reference}/returns", body, f"ret-{return_id}")


if __name__ == "__main__":
    print(pointy("GET", "/customers/01012345678"))
    e = earn("01012345678", 45000, "INV-1001")
    print("Points added:", e["points"])

JavaScript (Node.js 18+)

import { randomUUID } from 'node:crypto';

const POINTY_URL = 'https://api.point-y.com/api/v1/erp';

class PointyError extends Error {
  constructor(status, body) {
    super(body.message ?? `Pointy error ${status}`);
    this.status = status;
    this.error = body.error;
  }
}

async function pointy(method, path, body, idempotencyKey) {
  const headers = {
    Authorization: `Bearer ${process.env.POINTY_API_KEY}`,
    Accept: 'application/json',
    'Accept-Language': 'ar',
    ...(body ? { 'Content-Type': 'application/json' } : {}),
    ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
  };
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(POINTY_URL + path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });
    } catch (err) {
      if (attempt === 3) throw err;
      await new Promise((r) => setTimeout(r, 2000 * attempt)); // a timeout: try again with the same key (it is safe)
      continue;
    }
    if ((res.status === 429 || res.status >= 500) && attempt < 3) {
      await new Promise((r) => setTimeout(r, 2000 * attempt));
      continue;
    }
    const data = await res.json().catch(() => ({}));
    if (!res.ok) throw new PointyError(res.status, data);
    return data;
  }
}

// Adds points for a final invoice. Throws PointyError with a message for the cashier.
async function earn(customer, amountPiasters, invoiceNumber) {
  try {
    const r = await pointy('POST', '/earns', { customer, invoice_amount_piasters: amountPiasters, invoice_number: invoiceNumber }, `inv-${invoiceNumber}-${randomUUID()}`);
    return r.data;
  } catch (e) {
    if (e.error === 'invoice_already_earned') {
      const r = await pointy('GET', `/earns?invoice_number=${encodeURIComponent(invoiceNumber)}`);
      return r.data[0];
    }
    throw e;
  }
}

// Returns part of an invoice (or all of it with amountPiasters = null).
// On 'shortfall_settlement_required', ask the cashier and call again with
// shortfallSettlement 'customer_paid' or 'merchant_absorbed'.
async function returnInvoice(reference, amountPiasters, reason, returnId, shortfallSettlement) {
  const body = { reason };
  if (amountPiasters != null) body.invoice_amount_piasters = amountPiasters;
  if (shortfallSettlement) body.shortfall_settlement = shortfallSettlement;
  return pointy('POST', `/earns/${reference}/returns`, body, `ret-${returnId}`);
}

const e = await earn('01012345678', 45000, 'INV-1001'); // in an ES module
console.log('Points added:', e.points);

Good practice