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
- The shop owner subscribes the branch to the ERP link (merchant dashboard, ربط الـ ERP) and creates an API key there. A key belongs to one branch; create one per branch or per POS.
- The key is shown once. Keep it on your server, never in a browser or a mobile app. If it leaks, the owner revokes it and creates a new one.
- Points follow the shop's cashback settings (rate, minimum invoice, maximum per sale) exactly as when a cashier adds them.
Basics
- Base URL:
https://api.point-y.com/api/v1/erp - Auth:
Authorization: Bearer pty_... - JSON in and out:
Content-Type: application/json,Accept: application/json.Accept-Language: arorenpicks the language of error messages. - Money is in piasters (integer; 1 EGP = 100 piasters). Points are integers.
- Every
POSTthat changes something needs anIdempotency-Keyheader (any unique string up to 100 characters, e.g. a UUID per invoice). Retrying with the same key and body returns the first response again (Idempotent-Replayed: true), so a timeout never adds points twice. The same key with a different body is refused (409 idempotency_key_reused). - Limit: 300 requests a minute per key (
429). - Customers are identified by their Egyptian mobile number (
01012345678,+201012345678) or their Pointy membership code. A number that is not on Pointy yet still gets the points; they wait for the customer to sign up.
Errors
{ "error": "<reason>", "message": "<readable text>", "context": { ... } } with an HTTP status. Validation errors are 422 with errors per field.
| Status | error | Meaning |
|---|---|---|
| 401 | api_key_invalid | Wrong or revoked key |
| 402 | api_not_subscribed | The branch's ERP link is not paid or has ended |
| 403 | merchant_inactive, subscription_expired, not_allowed | The shop or branch cannot add points now |
| 409 | invoice_already_earned | Points were already added for this invoice number (context.reference) |
| 422 | invoice_below_minimum, exceeds_cashback_per_earn, merchant_credit_limit, customer_blocked, invalid_phone | The sale gets no points; show the message to the cashier |
| 422 | shortfall_settlement_required | A 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
- Call
POST /earnsafter the sale is final. Retry on timeouts or 5xx with the same Idempotency-Key; start a new key only for a new attempt after a refusal. - Show the
messageof a refusal to the cashier; the sale itself does not depend on Pointy. - Never log or print the API key.