Partner API · v1

Partner API Documentation

Integrate RunnerGeeks event photos, photo search (face & BIB), cart, and checkout into your mobile app — from credential setup to calling every endpoint.

Production
https://api.runnergeeks.id/api/partner/v1

Live data and real payments. Use your production credentials.

Endpoints Overview

All 36 operations live under /api/partner/v1/, grouped below by area. Your server holds the partner token (OAuth2 client_credentials — never ship the secret to the app) and uses it for events, user sync and webhooks; everything a runner sees or buys is called with that runner's user token (Firebase ID token), while the token endpoints themselves take no credential at all.

  • 36 operations
  • 5 partner token
  • 28 user token
  • 3 no token

Auth & tokens 3

  • Exchange client_id + client_secret for a partner token

    none — no token required
  • Exchange a sync custom_token for an end-user id_token (no Firebase SDK needed)

    none — no token required
  • Refresh an expiring id_token; the refresh token is rotated

    none — no token required

User sync 1

  • Register/link an end-user, get a Firebase custom token

    partner — partner token — server-to-server

Events 1

  • List events owned by this organizer

    partner — partner token — server-to-server

Webhooks 3

  • Register a webhook URL (returns secret once)

    partner — partner token — server-to-server
  • Webhook status + last 20 deliveries

    partner — partner token — server-to-server
  • DELETE /webhooks

    Unregister the webhook

    partner — partner token — server-to-server

Face ID 5

  • POST /faces

    Register a Face ID: 1–5 selfies, embedded + stored

    user — end-user token — Firebase ID token
  • GET /faces

    List the user's registered Face IDs

    user — end-user token — Firebase ID token
  • Read one Face ID

    user — end-user token — Firebase ID token
  • Update a Face ID (rename / set primary)

    user — end-user token — Firebase ID token
  • Delete a Face ID

    user — end-user token — Firebase ID token

Photos & search 4

  • Event photos — filter by bib or search_id (event_id required)

    user — end-user token — Firebase ID token
  • Face search using the registered Face ID (no upload) → returns search_id

    user — end-user token — Firebase ID token
  • Grab-all metadata for a seed photo: event, seller, bulk pricing (no search run)

    user — end-user token — Firebase ID token
  • Every photo matched to the same runner as the seed photo (max 200)

    user — end-user token — Firebase ID token

Cart 6

  • GET /cart

    Cart contents + live price quote

    user — end-user token — Firebase ID token
  • Add a photo to the cart

    user — end-user token — Firebase ID token
  • Add up to 200 photos at once — partially succeeds, reports added[] / skipped[]

    user — end-user token — Firebase ID token
  • Remove one cart item

    user — end-user token — Firebase ID token
  • DELETE /cart

    Empty the cart

    user — end-user token — Firebase ID token
  • Checkout: cart → order (prices snapshotted)

    user — end-user token — Firebase ID token

Orders 9

  • POST /orders

    Create an order directly (no cart)

    user — end-user token — Firebase ID token
  • Order history

    user — end-user token — Firebase ID token
  • Order detail

    user — end-user token — Firebase ID token
  • Payment methods the buyer may pick — send an id back as payment_method

    user — end-user token — Firebase ID token
  • Pay for an order

    user — end-user token — Firebase ID token
  • Poll payment status

    user — end-user token — Firebase ID token
  • Buyer approves a delivered item — records acceptance of the file

    user — end-user token — Firebase ID token
  • Buyer rejects a delivered item — back to pending, seller re-delivers (not a refund)

    user — end-user token — Firebase ID token
  • Cancel an order

    user — end-user token — Firebase ID token

Favorites 3

  • Toggle a favorite

    user — end-user token — Firebase ID token
  • List favorites

    user — end-user token — Firebase ID token
  • Favorite counts per type

    user — end-user token — Firebase ID token

Purchased photos 1

  • The end-user's bought photos within your organizer scope

    user — end-user token — Firebase ID token

Setup & Credentials

Integration uses two kinds of credentials, each with a different job:

CredentialHeld byUsed for
partner token (rgp_…, 30 min)Partner backend (server-to-server)Organizer-level only: list events, user sync, webhooks
user token (Firebase ID token)Mobile app (per end-user)Personal: Face ID, photos, BIB/face search, cart, orders, favorites

1.1 Via the dashboard Recommended

Takes about 2 minutes. No code required.

  1. Sign in as an organizer

    Go to runnergeeks.id/login and click Sign in with Google using your organizer Google account. All roles sign in via Google by default — email/password is not used.

  2. Open the Developers dashboard

    In the left sidebar go to Studio → API & Webhooks. The page opens on the Credentials tab by default.

  3. Create a new credential

    Click + New credential and fill in the form:

    • Name — a label you'll recognise (e.g. Acme Race App). Only visible to you.
    • Scopes — tick exactly what your integration needs. The table below lists what each scope unlocks.
    ScopeTypeWhat it's for
    events:readscopeList events owned by the organizer
    users:syncscopeRegister/link an end-user
    webhooks:managescopeManage the webhook endpoint
  4. Save your credentials — now, once only

    After clicking Create, the dialog shows your client_id and client_secret.

    Credential dialog

    Client ID

    rgpc_xxxxxxxxxxxxxxxx

    Copy safe to share

    Client Secret

    rgps_••••••••••••••••••••••••••••••••

    Shown once only

    The client_secret cannot be retrieved after this point. Copy it immediately into a server environment variable or secret manager. If you lose it, use Rotate secret on the credentials table to generate a new one.

  5. Exchange for a partner token

    From your server, use the credentials to call POST /oauth/token (§ 2.1) and receive a short-lived partner token. The mobile app never sees client_id or client_secret.

    Admin oversight: RunnerGeeks staff (SuperAdmin) can list, suspend, revoke or adjust any partner client from the admin console — a compromised client can be killed centrally.

Authentication

2.1 Partner token (OAuth2 client_credentials)

Your server exchanges the client_id + client_secret issued at onboarding for an access token:

Request
POST /api/partner/v1/oauth/token
Content-Type: application/json

{
  "grant_type": "client_credentials",
  "client_id": "rgpc_xxxxxxxxxxxxxxxx",
  "client_secret": "rgps_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

Response — 200 OK:

Response · 200
{
  "code": 0,
  "success": true,
  "message": null,
  "data": {
    "access_token": "rgp_3d8dcd53009ab2…",
    "token_type": "Bearer",
    "expires_in": 1800,
    "scope": "events:read users:sync webhooks:manage"
  }
}
data fieldsTypeWhat it's for
access_tokenstringBearer token for all partner endpoints (prefix rgp_)
token_typestringAlways Bearer
expires_inintSeconds until expiry (1800 = 30 min)
scopestringSpace-separated scopes granted to this client
  • The token is valid for 30 minutes (expires_in seconds). Cache and reuse it until close to expiry — never fetch a fresh token per call.
  • Any grant_type other than client_credentials → 400.
  • Wrong credentials → 401. Suspended/revoked client → 403.

2.2 User token (end-user in the mobile app)

End-users are your app's runners. They link to RunnerGeeks accounts (auto-bound by email — no duplicate account) like this:

Account linking flow

Step by step

  1. User → your app

    Signs in with your own auth

  2. Your app → your server

    Hands over the signed-in identity

    external_id, email, name
  3. Your server → RunnerGeeks

    Links or creates the RunnerGeeks account

    POST /users/sync (partner token)
  4. RunnerGeeks → your server

    Returns the linked user and a one-shot custom token

    { user_id, custom_token }
  5. Your server → your app

    Passes only the custom token — never the partner token

  6. Your app → RunnerGeeks

    Trades it for an end-user session

    POST /auth/exchange
  7. RunnerGeeks → your app

    Returns the session the app will carry

    { id_token, refresh_token, expires_in }
  8. Your app → RunnerGeeks

    Calls every user-level endpoint with that id_token

    Authorization: Bearer <id_token>
  9. Your app → RunnerGeeks

    Refreshes before the hour is up; store the rotated token

    POST /auth/refresh

Step 1 — your server calls sync (partner token):

Request
POST /api/partner/v1/users/sync
Authorization: Bearer <partner_token>
Content-Type: application/json

{
  "external_id": "user-12345",
  "email": "runner@example.com",
  "name": "Budi Runner",
  "email_verified": true
}
Request bodyTypeWhat it's for
external_idstringUser ID in the partner system (required, unique)
emailstringUser email — used for auto-bind (required, valid)
namestringFull name shown on RunnerGeeks (required)
email_verifiedboolOptional; ignored unless the client has the trusted_email flag

Response — 200 OK:

Response · 200
{
  "code": 0,
  "success": true,
  "data": {
    "user_id": "300df6f5-9385-4b77-aed5-8b3fe54230ef",
    "firebase_uid": "LzfWajvertheqQeLnNQXRtLbuAg2",
    "created": true,
    "custom_token": "eyJhbG…9In0…"
  }
}
data fieldsTypeWhat it's for
user_iduuidRunnerGeeks user UUID
firebase_uidstringFirebase UID behind the account
createdbooltrue if new account, false if already existing (idempotent)
custom_tokenstringFirebase custom token — exchange it for an ID token
  • Idempotent: calling again with the same external_id returns the same user_id with created: false. Call sync on every app login — cheap and safe.
  • Auto-bind: if the email is already registered on RunnerGeeks (e.g. the user once signed in on the web via Google), the same account is reused.

Step 2 — the app exchanges the custom token through RunnerGeeks:

Request
POST /api/partner/v1/auth/exchange
Content-Type: application/json

{ "custom_token": "<custom_token from step 1>" }
Response · 200
{
  "id_token": "eyJhbGciOiJSUzI1NiIs…",
  "refresh_token": "AMf-vBx…",
  "expires_in": 3600,
  "token_type": "Bearer"
}

The id_token is the <enduser_token>. This endpoint takes no Authorization header — the custom token is itself the proof, and only RunnerGeeks can mint one.

Step 3 — refresh before it expires (id_token lasts 1 hour):

Request
POST /api/partner/v1/auth/refresh
Content-Type: application/json

{ "refresh_token": "<most recent refresh_token>" }

Same response shape. Firebase rotates the refresh token — store the new one each time and discard the old.

Why through RunnerGeeks: your app needs neither the Firebase SDK nor any API key — just plain HTTP to one host. It also removes the most common integration mistake: trying to exchange the custom token against your own Firebase project. That always fails with INVALID_CUSTOM_TOKEN, because the token is signed by RunnerGeeks' service account for a uid inside RunnerGeeks' project.

2.3 Partner scopes

ScopeTypeWhat it's for
events:readscopeList events owned by the organizer
users:syncscopeRegister/link an end-user
webhooks:managescopeManage the webhook endpoint

Photos, BIB/face search and Face ID registration are end-user operations (user token), not partner scopes — see § 4.

Organizer Endpoints

Org-level, called server-to-server with the <partner_token>. Event reads are automatically scoped to the organizer behind the client — another organizer's event reads as 404.

3.1 List events

GET /events partner token

Only events owned by the organizer behind the credential in use.

Request
GET /api/partner/v1/events?page=1&per_page=20
Authorization: Bearer <partner_token>

Response — 200 OK:

Response · 200
{
  "items": [
    {
      "id": "00000000-…-000000000002",
      "name": "Smoke Test Race",
      "description": null,
      "location": null,
      "event_date": null,
      "status": "published",
      "slug": "smoke-test-race",
      "thumbnail_url": "https://img.runnergeeks.id/events/…/thumb.jpg",
      "banner_url": "https://img.runnergeeks.id/events/…/banner.jpg",
      "bib_search_support": false
    }
  ],
  "page": 1,
  "per_page": 20
}
items[] fieldsTypeWhat it's for
iduuidEvent UUID
namestringEvent name
descriptionstring?Event description (nullable)
locationstring?Event location (nullable)
event_datedate?Event date (nullable)
statusstringe.g. published
slugstringURL slug
thumbnail_urlstring?Event cover image — falls back to banner_url when no dedicated thumbnail is set
banner_urlstring?Event banner image (nullable)
bib_search_supportboolWhether BIB search is enabled for this event

3.2 Webhooks

Register a URL to receive notifications (details in the Webhooks section):

Request
POST /api/partner/v1/webhooks
Authorization: Bearer <partner_token>
Content-Type: application/json

{ "url": "https://partner.example.com/rg-webhook" }

Response — 200 OK:

Response · 200
{
  "secret": "rgwh_…",
  "handshake_delivery_id": "…"
}
data fieldsTypeWhat it's for
secretstringHMAC secret (rgwh_…) — shown ONCE, cannot be retrieved later
handshake_delivery_iduuidDelivery ID of the handshake.ping verification event

Other webhook operations

GET /webhooks partner token

Current webhook configuration plus the 20 most recent deliveries, newest first — enough to inspect retries and dead letters without a dashboard. Requires the webhooks:manage scope.

Response · 200
{
  "url": "https://partner.example.com/rg-webhook",
  "configured": true,
  "recent_deliveries": [
    {
      "id": "0f5c1d3a-…",
      "event_type": "handshake.ping",
      "status": "delivered",
      "attempt_count": 1,
      "next_retry_at": null,
      "last_response_code": 200
    }
  ]
}
pending
queued or waiting on the next retry
delivered
your endpoint answered 2xx
dead
retries exhausted — no further attempts
DELETE /webhooks partner token

Unregisters the endpoint and clears the signing secret. Registering again issues a new secret — rotate by calling POST /webhooks, which replaces both in one step.

Response · 200
{ "disabled": true }

End-User Endpoints

Called by the mobile app directly with the end-user's token — Authorization: Bearer <enduser_token> (the Firebase id_token from § 2.2). Never send the <partner_token> here.

Access gate: the token must belong to a user synced through a partner (POST /users/sync). A plain RunnerGeeks token → 403. Event reads (photos, face search) are tenant-scoped: the event_id must belong to your partner's organizer, otherwise 404.

4.1 Face ID (register)

A Face ID is the user's set of reference selfies. Upload 1–5 photos per call, base64-encoded (JPEG, PNG or WebP, max 5 MB each). The whole set is one change. Each photo is checked for a face and embedded before the response, then discarded. A photo with no face fails the whole set with 400. More reference angles improve matching.

POST /faces user token

Enrol one set of 1–5 base64 reference photos (images[], optional angles[]). Returns one profile per photo, already embedded. Errors: 400 no face / bad image, 409 over 5 photos, 429 over 3 sets per rolling 7 days (Retry-After).

Request
POST /api/partner/v1/faces
Authorization: Bearer <enduser_token>
Content-Type: application/json

{
  "name": "My Face",
  "angles": ["front", "left", "right", "up", "down"],
  "images": ["<base64 JPEG>", "<base64 JPEG>", "…"]
}
Request bodyTypeWhat it's for
namestringLabel for the Face ID (1–255 chars, required)
imagesstring[]1–5 base64-encoded photos (JPEG, PNG or WebP, ≤ 5 MB each). Embedded, then discarded. One request is one change.
anglesstring[]?Optional: front | left | right | up | down per image, same order as images

Response — 200 OK (one object per photo):

Response · 200
{
  "code": 0,
  "success": true,
  "data": [
    {
      "id": "e7c1b0a2-…",
      "name": "My Face · front",
      "photo_url": null,
      "is_primary": false,
      "has_embedding": true,
      "created_at": "2026-10-18T09:00:00Z",
      "updated_at": "2026-10-18T09:00:00Z"
    }
  ]
}
data[] fieldsTypeWhat it's for
iduuidFace profile (reference) id
namestringProfile label (includes the angle when given)
photo_urlnullAlways null for partner uploads. No image is stored.
is_primaryboolfalse on creation; set the primary with PUT /faces/{id}
has_embeddingbooltrue once the photo is embedded, which is already true in the response
LimitsTypeWhat it's for
5 photosper userA Face ID holds at most 5 reference photos. Over the limit → 409.
3 setsper 7 daysRolling 7 days from each accepted set. Only accepted (2xx) requests count; 400, 409 and 429 do not. Deleting a profile does not free a slot. Over the limit → 429 with Retry-After (seconds) until the oldest set expires.
anglesguidanceUse a frontal photo plus a few different angles for better matching. Not enforced.

Each selfie's 512-dim embedding is generated and stored during registration. The image itself is never stored. The embedding is reused for face search without re-uploading. Manage Face IDs:

Face ID CRUD
GET    /api/partner/v1/faces        list Face IDs
GET    /api/partner/v1/faces/{id}   read one
PUT    /api/partner/v1/faces/{id}   update (name / is_primary)
DELETE /api/partner/v1/faces/{id}   delete

4.2 Photos

GET /photos user token

One endpoint for event photos. event_id is always required. Add bib to filter by BIB, or search_id to read a face-search result. Same response envelope and item shape as the main GET /api/photos: items[], total, page, per_page of PhotoWithPrice (source_url, signed_url, effective_price, owner…).

Three modes, selected by query params — bib and search_id are mutually exclusive (sending both → 400):

ParamRequiredNotes
event_idyesUUID of the event to list photos from
bibnoBIB number → BIB filter (needs bib_search_support)
search_idnohandle from POST /search/face-id → face-results mode
distanceno0 = exact (default), 1 = tolerate one digit (OCR slip) — with bib
page, per_pagenopagination (per_page max 100)

a) All photos of an event

Request
GET /api/partner/v1/photos?event_id={uuid}&page=1&per_page=20
Authorization: Bearer <enduser_token>

Response — 200 OK:

Response · 200
{
  "items": [
    {
      "id": "7dfa8f07-…",
      "filename": "AP1GczM_…",
      "source_url": "https://lh3.googleusercontent.com/pw/…",
      "source_provider": "google_photos",
      "width": null,
      "height": null,
      "filesize": 682588,
      "capture_date": "2026-09-05T10:12:06Z",
      "event_id": "444bba10-…",
      "album_id": "47ac574d-…",
      "is_for_sale": false,
      "price_override": null,
      "effective_price": "0.00",
      "album_name": "4. [MTR26] Geger Sapi 50K & 25K",
      "event_name": "Merbabu Trail Run 2026",
      "owner": { "display_name": "Rimshot ID", "photo_url": "…" }
    }
  ],
  "total": 84090,
  "page": 1,
  "per_page": 20
}
items[] fieldsTypeWhat it's for
iduuidPhoto UUID
filenamestringProvider file name
source_urlstringThe photo's URL. For a photo that is not for sale this is the original. For a photo on sale it is replaced by the same signed, watermarked URL as signed_url — the listing never hands out the original of something that costs money. Signed URLs expire, so do not store them
source_providerstringe.g. google_photos
width / heightint?Original pixel size (nullable until probed)
filesizelongBytes of the original
capture_datedate?EXIF capture time (nullable)
album_name / event_namestringHuman-readable album and event
is_for_saleboolWhether the photo is purchasable
signed_urlstring?Short-lived watermarked preview URL — present only when is_for_sale is true, and equal to source_url then
effective_pricedecimalprice_override or the event base price
ownerobjectAlbum owner: display_name, photo_url
totalintTotal photos in the event (for pagination)
page / per_pageintEcho of the requested page

b) Filter by BIB

BIB numbers come from photo indexing. Events without BIB support (bib_search_support: false) → 400. Each item gains a bib_match.

Request
GET /api/partner/v1/photos?event_id={uuid}&bib=10045&distance=0&page=1
Authorization: Bearer <enduser_token>

Response — 200 OK:

Response · 200
{
  "items": [
    {
      "id": "758ba467-…",
      "source_url": "https://lh3.googleusercontent.com/pw/…",
      "album_name": "27. [MTR26] Perkampungan KM 5.5",
      "event_name": "Merbabu Trail Run 2026",
      "effective_price": "0.00",
      "owner": { "display_name": "Rimshot ID" },
      "bib_match": { "bib_number": "1070", "distance": 0 }
    }
  ],
  "total": 59,
  "page": 1,
  "per_page": 20
}
items[] fieldsTypeWhat it's for
…photo fields—Same item shape as the photos endpoint (§ above)
bib_match.bib_numberstringBIB as indexed from the photo
bib_match.distanceintEdit distance from the query (0 = exact)
total / page / per_pageintEntriesResult wrapper, same as photos

c) Face-search results

Once you have a search_id from POST /search/face-id (§ 4.3), read the matched photos here. Items carry the full photo shape plus similarity_score and angle_support. Render each photo from its signed_url (on sale) or source_url (free); preview_url is reserved and currently always empty.

Request
GET /api/partner/v1/photos?event_id={uuid}&search_id={search_id}&page=1&per_page=20
Authorization: Bearer <enduser_token>

Response — 200 OK:

Response · 200
{
  "items": [
    {
      "id": "7dfa8f07-…",
      "filename": "AP1GczM_…",
      "source_url": "https://img.runnergeeks.id/image/<sig>/7dfa8f07-…?expires=1760000000",
      "source_provider": "google_photos",
      "is_for_sale": true,
      "signed_url": "https://img.runnergeeks.id/image/<sig>/7dfa8f07-…?expires=1760000000",
      "effective_price": "14000.00",
      "album_name": "27. [MTR26] Perkampungan KM 5.5",
      "event_name": "Merbabu Trail Run 2026",
      "owner": { "display_name": "Rimshot ID" },
      "photo_id": "7dfa8f07-…",
      "similarity_score": 0.71,
      "preview_url": "",
      "angle_support": 2
    }
  ],
  "total": 14,
  "page": 1,
  "per_page": 20
}
items[] fieldsTypeWhat it's for
…photo fields—Same item shape as the default photos listing (§ 4.2a): source_url, signed_url, effective_price, owner…
photo_iduuidMatched photo id (echo)
similarity_scorefloatMatch similarity 0–1 (higher = closer)
preview_urlstringReserved — currently always an empty string. Render the result from the item's signed_url (on sale) or source_url (free), not from this field
angle_supportint?How many reference angles matched (null for single-angle searches)
total / page / per_pageintSame EntriesResult wrapper as the other modes

4.3 Face search (registered Face ID)

Face search uses the user's registered Face ID only — the stored embeddings, no image upload. There is deliberately no search-by-image endpoint, so a user can never submit an arbitrary photo to look up someone else (anti-abuse, PDP). The only upload is the user's own reference photo at registration (§4.1). Requires at least one Face ID whose embedding has finished (otherwise 403 — register via POST /faces). event_id is required and must belong to your partner's organizer. Returns a search_id; read the matched photos via GET /photos?event_id=…&search_id=… (§ 4.2c).

POST /search/face-id user token

JSON body with event_id (required); threshold/limit/album_id optional. No image upload.

Request
POST /api/partner/v1/search/face-id
Authorization: Bearer <enduser_token>
Content-Type: application/json

{ "event_id": "{uuid}", "threshold": 0.55, "limit": 20 }

Response — 200 OK:

Response · 200
{
  "search_id": "7c2a…",
  "total_matches": 14
}
data fieldsTypeWhat it's for
search_iduuidHandle for the started search
total_matchesintTotal matched photos

4.4 Cart (server-side)

The cart lives on RunnerGeeks — one live cart per end user, consistent across devices, so a phone and a tablet signed in as the same user share it. All six calls take the end-user token, never the partner token, and every response is wrapped in the standard envelope { code, success, message, data } — the bodies below show data.

Cart endpoints
GET    /api/partner/v1/cart               contents + live quote
POST   /api/partner/v1/cart/items         add one photo
POST   /api/partner/v1/cart/items/batch   add up to 200 photos at once
DELETE /api/partner/v1/cart/items/{id}    remove one item
DELETE /api/partner/v1/cart               empty the cart
POST   /api/partner/v1/cart/checkout      SNAPSHOT: cart -> order

The price model — read this before you build a cart screen

Nothing in the cart stores a price. GET /cart prices the current contents live, so its numbers are indicative: they can move if the organizer changes pricing, or if a photo you add joins a bulk package. The price that counts is computed again, server-side, inside the checkout transaction and frozen there into the order's items.

So: display the quote, never compute or cache your own totals, and always show the buyer the total returned by checkout before sending them to pay. Monetary values are JSON strings ("28000.00") — parse them as decimals, not floats.

Read the cart + live quote

GET /cart user token

Cart contents priced at this instant, with a signed preview per photo and the bulk packages already grouped. Read-only — it creates nothing. An empty cart is a 200 with items: [], groups: [], subtotal: "0", currency: "IDR", item_count: 0.

Request
GET /api/partner/v1/cart
Authorization: Bearer <enduser_token>

Response — 200 OK:

Response · 200
{
  "items": [
    {
      "cart_item_id": "9f1c2b84-7d3e-4a51-9c20-5b7e8a1d4f06",
      "photo_id": "3b8a1f22-64c7-4d19-8e35-c0a9d7b21e54",
      "item_type": "photo",
      "face_id": null,
      "notes": null,
      "unit_price": "12500.00",
      "thumbnail_url": "https://img.runnergeeks.id/thumbnail/<sig>/150x200/3b8a1f22-…?expires=1760000000",
      "from_bulk": true,
      "group_key": "2f7b6c51-83a9-4d07-b1e8-5a94c3d07f62|8e1a4d60-72c5-4b39-90fd-3a07e2c15b84"
    },
    {
      "cart_item_id": "a27d6e30-1b94-4c88-bf52-9d31e0a7c4b8",
      "photo_id": "7c45d9e1-0a62-4b37-95fd-2e81c6a0b739",
      "item_type": "photo",
      "face_id": null,
      "notes": null,
      "unit_price": "12500.00",
      "thumbnail_url": "https://img.runnergeeks.id/thumbnail/<sig>/150x200/7c45d9e1-…?expires=1760000000",
      "from_bulk": true,
      "group_key": "2f7b6c51-83a9-4d07-b1e8-5a94c3d07f62|8e1a4d60-72c5-4b39-90fd-3a07e2c15b84"
    },
    {
      "cart_item_id": "c8e0412f-6a95-4d17-80b3-7e2f9a1c5d64",
      "photo_id": "0d91ba47-5e38-4c12-aa76-1f20e8c5d913",
      "item_type": "photo",
      "face_id": null,
      "notes": null,
      "unit_price": "14000.00",
      "thumbnail_url": "https://img.runnergeeks.id/thumbnail/<sig>/150x200/0d91ba47-…?expires=1760000000",
      "from_bulk": false
    }
  ],
  "groups": [
    {
      "key": "2f7b6c51-83a9-4d07-b1e8-5a94c3d07f62|8e1a4d60-72c5-4b39-90fd-3a07e2c15b84",
      "event_id": "2f7b6c51-83a9-4d07-b1e8-5a94c3d07f62",
      "seller_user_id": "8e1a4d60-72c5-4b39-90fd-3a07e2c15b84",
      "event_name": "Jakarta Marathon 2026",
      "item_count": 2,
      "cart_item_ids": [
        "9f1c2b84-7d3e-4a51-9c20-5b7e8a1d4f06",
        "a27d6e30-1b94-4c88-bf52-9d31e0a7c4b8"
      ],
      "package_price": "25000.00",
      "individual_total": "28000.00",
      "savings": "3000.00"
    }
  ],
  "subtotal": "39000.00",
  "currency": "IDR",
  "item_count": 3
}
data fieldsTypeWhat it's for
itemsarrayCart rows, oldest first (ordered by created_at ascending)
groupsarrayBulk packages in this cart, grouped by the API. Empty when nothing is bulk-priced. Render these — do not rebuild the grouping or the package price on the client
subtotaldecimal stringSum of every items[].unit_price. Indicative — no coupon, tax or payment fee is applied here
currencystringISO code of the first cart photo (e.g. "IDR"); "IDR" when the cart is empty
item_countintNumber of rows in items[] — handy for a cart badge
items[] fieldsTypeWhat it's for
cart_item_iduuidThe cart row id. This — not photo_id — is the {id} for DELETE /cart/items/{id}
photo_iduuidThe photo this row buys
item_typestringAlways "photo" in v1 ("face" is reserved)
face_iduuid | nullReserved for face-level purchase context; v1 still prices per photo
notesstring | nullFree-text note sent when the item was added (max 500 chars)
unit_pricedecimal stringLive effective price for this row. For a packaged item this is its share of the package price, spread across the group. Indicative until checkout
thumbnail_urlstringSigned preview of the photo — never the original. Short-lived: re-read the quote instead of caching it, and never store it as the photo's address
from_bulkboolTrue when the item came from a bulk selection (grab all). Only these are eligible for a package price; false means the buyer picked the photo on its own
group_keystring · omittedPresent only on an item priced inside a package; matches groups[].key. Absent means the item is priced on its own
groups[] fieldsTypeWhat it's for
keystring"{event_id}|{seller_user_id}" — the same pair the order pipeline groups on, so the package you show is the one checkout charges
event_iduuidEvent the package belongs to
seller_user_iduuidPhotographer who set the package price
event_namestring | nullEvent name, null when empty
item_countintNumber of photos priced together
cart_item_idsuuid[]The cart rows in this package — resolve them against items[]. One of them may have been removed since the quote, so tolerate an id with no row
package_pricedecimal stringWhat the package costs together, exactly as the seller configured it
individual_totaldecimal stringWhat the same photos would cost one by one
savingsdecimal stringindividual_total − package_price, floored at "0". A package is whatever the seller configured, so it can cost the same as — or more than — buying the photos separately; when savings is "0", do not advertise a discount

Which photos get a package price

Only items with from_bulk: true — the ones added through grab all — and only when the photographer has set a bulk-pricing config for that event. Whatever that config says is the price: a flat package, or a tier discount on the group's sum. Nothing is added on top of it, there is no minimum purchase beyond what the config itself states, and the server does not substitute a cheaper price when the package happens to cost more.

A photo the runner added on its own (from_bulk: false) keeps its per-photo price even when the event has a package, and the same holds at checkout. The packaged items' share of the package price is spread across them, so each unit_price carries its part and subtotal is simply the sum.

Draw the cart from groups[]: one card per package, the items it lists inside it, everything else as singles. Do not group by event or recompute the package on the client — the key is the pair the order pipeline groups on, so what you show is what checkout charges.

thumbnail_url is signed and expires, so render it straight from the quote you just read — there is nothing to remember at add time, and nothing stale after a restart. If a photo in the cart has since been deleted, the whole quote fails with 400 A photo in your cart is no longer available — drop the stale rows and re-read.

Add an item

POST /cart/items user token

Adds one photo. Idempotent on (user, photo, item_type): adding the same photo twice does not duplicate the row and does not error — the existing row comes back. from_bulk can be raised by a repeat add, never lowered.

Request
POST /api/partner/v1/cart/items
Authorization: Bearer <enduser_token>
Content-Type: application/json

{
  "photo_id": "3b8a1f22-64c7-4d19-8e35-c0a9d7b21e54",
  "face_id": null,
  "notes": null,
  "from_bulk": false
}
Request bodyTypeWhat it's for
photo_iduuid · requiredPhoto to add. Must exist, be for sale, and not belong to the buyer
face_iduuid | null · optionalReserved for face-level purchase; stored on the row, does not change the price in v1
notesstring | null · optionalPer-item note, max 500 characters
from_bulkbool · optional · default falseSet when the photo came from a bulk selection (grab all). Only bulk items can take the event's package price; leave it false for a photo the runner picked on its own, or they get a discount they did not qualify for

Response — 200 OK, the stored cart row:

Response · 200
{
  "id": "9f1c2b84-7d3e-4a51-9c20-5b7e8a1d4f06",
  "user_id": "5d2e7a10-93bc-4f68-a1d7-0c84b35e9f21",
  "photo_id": "3b8a1f22-64c7-4d19-8e35-c0a9d7b21e54",
  "item_type": "photo",
  "face_id": null,
  "notes": null,
  "created_at": "2026-10-14T03:21:08.412Z",
  "updated_at": "2026-10-14T03:21:08.412Z",
  "from_bulk": false
}
data fieldsTypeWhat it's for
iduuidCart row id — the same value the quote returns as cart_item_id
user_iduuidOwner of the cart (the end user the token belongs to)
photo_iduuidPhoto that was added
item_typestringAlways "photo" in v1
face_iduuid | nullAs sent in the request
notesstring | nullAs sent in the request
created_attimestampWhen the row was first created — unchanged on a repeat add
updated_attimestampLast change to the row
from_bulkboolWhether the row is a bulk item. A repeat add can raise it from false to true, never lower it

Note what the response does not contain: no price. Re-read GET /cart after adding if you need the updated quote — a bulk item may have just joined a package.

Error messageStatusWhen
Photo not found404photo_id does not exist
Photo {id} is not for sale400The photo is not flagged for sale by its owner
You cannot purchase your own photo400The photo belongs to the authenticated user

Add many items at once

The call behind grab all: a runner taps once and every photo they were matched to goes in the cart. Up to 200 photos per call, which is also the ceiling the grab-all photo list returns — one grab is always one request.

POST /cart/items/batch user token

Adds up to 200 photos in one call — the "grab all" basket. Marks them from_bulk by default, which is what makes them eligible for the event's package price. Partially succeeds by design: every id is reported in added or skipped and the call still returns 200. Duplicate ids are de-duplicated, and a photo already in the cart is not duplicated, so the whole batch is safe to retry.

Request
POST /api/partner/v1/cart/items/batch
Authorization: Bearer <enduser_token>
Content-Type: application/json

{
  "photo_ids": [
    "3b8a1f22-64c7-4d19-8e35-c0a9d7b21e54",
    "7c45d9e1-0a62-4b37-95fd-2e81c6a0b739",
    "0d91ba47-5e38-4c12-aa76-1f20e8c5d913"
  ],
  "notes": null,
  "from_bulk": true
}
Request bodyTypeWhat it's for
photo_idsuuid[] · required · 1–200Photos to add. An empty array is a 400, more than 200 is a 400, and repeated ids are de-duplicated before anything is written
notesstring | null · optionalOne note applied to every row this call creates — there is no per-photo note in the batch form
from_bulkbool · optional · default trueDefaults to true because this endpoint is the grab-all basket. Send false to add several hand-picked photos in one call without making them eligible for a package price

Response — 200 OK, even with failures in it:

Response · 200
{
  "added": [
    {
      "photo_id": "3b8a1f22-64c7-4d19-8e35-c0a9d7b21e54",
      "cart_item_id": "9f1c2b84-7d3e-4a51-9c20-5b7e8a1d4f06"
    },
    {
      "photo_id": "7c45d9e1-0a62-4b37-95fd-2e81c6a0b739",
      "cart_item_id": "a27d6e30-1b94-4c88-bf52-9d31e0a7c4b8"
    }
  ],
  "skipped": [
    {
      "photo_id": "0d91ba47-5e38-4c12-aa76-1f20e8c5d913",
      "reason": "Bad request: Photo 0d91ba47-5e38-4c12-aa76-1f20e8c5d913 is not for sale"
    }
  ],
  "added_count": 2,
  "skipped_count": 1
}
data fieldsTypeWhat it's for
addedarrayOne entry per photo now in the cart, including photos that were already there
skippedarrayOne entry per photo that could not be added, each with its own reason
added_countintadded.length — count after de-duplication, so it can be lower than the ids you sent
skipped_countintskipped.length
added[] fieldsTypeWhat it's for
photo_iduuidPhoto as you sent it
cart_item_iduuidCart row id — the handle for DELETE /cart/items/{id}, and the same value the quote returns
skipped[] fieldsTypeWhat it's for
photo_iduuidPhoto that was not added
reasonstringHuman-readable cause, prefixed by its class — e.g. "Not found: Photo not found", "Bad request: Photo {id} is not for sale", "Bad request: You cannot purchase your own photo". Show it, do not parse it

It partially succeeds on purpose

A 200 here does not mean everything went in. One photo the organizer has since taken off sale must not cost the runner the other forty, so each id is settled on its own and reported in added or skipped. Never treat the status code as the result: read skipped, and tell the runner what did not make it before they pay.

The two errors in the table below are different — they reject the whole call, write nothing, and return no added/skipped at all.

Safe to retry. A photo already in the cart is a no-op, not a skip: the existing row comes back in added with its original cart_item_id and nothing is duplicated. The one thing a repeat can change is from_bulk: it is raised from false to true, never lowered, so a photo the runner added on its own and later swept up with grab all joins its package instead of being stranded outside it. A batch that timed out mid-flight can simply be sent again, and sending the same set twice is harmless. Repeated ids inside a single call are de-duplicated first, which is why added_count + skipped_count can be lower than the number of ids you posted.

As with the single add, nothing here returns a price. Read GET /cart once afterwards — the batch is marked from_bulk by default, so it forms a package when the event has one, and that quote is the only place the package shows up.

Error messageStatusWhen
photo_ids must not be empty400The array was empty — nothing is written and there is no partial result
photo_ids accepts at most 200 photos per call400More than 200 ids in one call. Split them; the whole call is rejected, not trimmed

Remove one item

DELETE /cart/items/{id} user token

{id} is the cart item id — cart_item_id from the quote, or id from the add response. It is NOT the photo_id. Deletion is scoped to the cart's owner, so another user's item id is a 404, never a cross-account delete.

Request
DELETE /api/partner/v1/cart/items/9f1c2b84-7d3e-4a51-9c20-5b7e8a1d4f06
Authorization: Bearer <enduser_token>

Response — 200 OK:

Response · 200
{ "removed": true }

removed is always true on a 200: an id that matched nothing returns 404 Cart item not found instead.

Empty the cart

DELETE /cart user token

Deletes every row in the user's cart. Safe to call on an already-empty cart — that is a 200 with removed: 0, not an error.

Request
DELETE /api/partner/v1/cart
Authorization: Bearer <enduser_token>

Response — 200 OK:

Response · 200
{ "removed": 2 }

removed is the number of rows deleted. You do not need to call this after a successful checkout — checkout clears the cart itself.

Checkout — cart becomes an order

This is the snapshot moment, and the only call in this section that changes money. Everything currently in the cart goes into one order, priced once inside the order's own transaction, and the cart is emptied. The order comes back unpaid — pay it with POST /orders/{id}/pay.

POST /cart/checkout user token

Converts the whole cart into a single order. Both body fields are optional — an empty body {} is valid. Prices are frozen into the order's items here; the cart is cleared only after the order was created successfully.

Request
POST /api/partner/v1/cart/checkout
Authorization: Bearer <enduser_token>
Content-Type: application/json

{
  "coupon_code": "HEMAT10",
  "notes": "Kirim ke WhatsApp 08xx"
}
Request bodyTypeWhat it's for
coupon_codestring | null · optionalDiscount code. Trimmed and upper-cased server-side, validated against expiry, max uses and minimum subtotal
notesstring | null · optionalOrder-level note, stored on the order and visible to the organizer

Response — 200 OK. The order exists, prices are frozen, the cart is empty:

Response · 200
{
  "order": {
    "id": "6073b32d-91af-4c0e-8b27-d4e5a16c93f8",
    "order_number": "RG-708BC7EE2079",
    "subtotal": "28000.00",
    "discount": "2800.00",
    "tax": "0.00",
    "fee": "0.00",
    "total": "25200.00",
    "currency": "IDR",
    "status": "pending",
    "payment_status": "pending",
    "delivery_status": "pending",
    "items": [
      {
        "id": "b41e9d75-2c08-4a63-9f1b-83d0e7a52c46",
        "photo_id": "3b8a1f22-64c7-4d19-8e35-c0a9d7b21e54",
        "album_id": "c9d0a7b6-54e3-4219-8f7a-16bc2d049e83",
        "event_id": "2f7b6c51-83a9-4d07-b1e8-5a94c3d07f62",
        "event_name": "Jakarta Marathon 2026",
        "album_name": "KM 21 - Sudirman",
        "owner": {
          "id": "8e1a4d60-72c5-4b39-90fd-3a07e2c15b84",
          "display_name": "Foto Lari ID",
          "photo_url": null,
          "username": "fotolariid"
        },
        "price": "14000.00",
        "currency": "IDR",
        "thumbnail_url": "https://img.runnergeeks.id/thumbnail/<sig>/150x200/3b8a1f22-…?expires=1760000000",
        "source_url": null,
        "delivered_at": null,
        "approved_at": null,
        "refunded_at": null,
        "delivery_url": null
      }
    ],
    "payment": null,
    "notes": "Kirim ke WhatsApp 08xx",
    "buyer": null,
    "created_at": "2026-10-14T03:24:51.009Z",
    "updated_at": "2026-10-14T03:24:51.009Z"
  },
  "cleared_items": 2
}
data fieldsTypeWhat it's for
orderobjectThe created order — the same shape every /orders endpoint returns
cleared_itemsintHow many cart rows were deleted after the order was created. Non-zero means the cart is now empty
order fieldsTypeWhat it's for
iduuidOrder id — use it for /orders/{id}/pay and payment-status
order_numberstringHuman-readable reference, e.g. RG-708BC7EE2079 — show this to the buyer
subtotaldecimal stringFrozen sum of items[].price (bulk/tier pricing already applied)
discountdecimal stringCoupon discount; "0.00" when no coupon_code was sent
taxdecimal stringAlways "0.00" at checkout — reserved, no tax is computed in v1
feedecimal stringAlways "0.00" at checkout; the payment-channel fee is added later by POST /orders/{id}/pay
totaldecimal stringAmount payable = subtotal − discount. Recalculated to subtotal − discount + fee once a payment method is chosen
currencystringISO code, e.g. "IDR"
statusstring"pending" on creation → completed / cancelled
payment_statusstring"pending" on creation → paid / failed / expired / cancelled
delivery_statusstring"pending" on creation — flips once the organizer delivers
itemsarrayOne row per purchased photo, with its frozen price
paymentobject | nullAlways null here — no payment exists until POST /orders/{id}/pay
notesstring | nullThe note sent in the request
buyerobject | nullAlways null here — populated on GET /orders/{id}
created_attimestampWhen the order was created
updated_attimestampLast change to the order
order.items[] fieldsTypeWhat it's for
iduuidOrder item id (used by delivery endpoints)
photo_iduuidPurchased photo
album_iduuid | nullAlbum the photo belongs to
event_iduuid | nullEvent the photo belongs to
event_namestring | nullEvent name, null when empty
album_namestring | nullAlbum name, null when empty
ownerobject | nullSeller: id, display_name, photo_url, username
pricedecimal stringFrozen price for this photo — never recomputed after checkout
currencystringCurrency of this item
thumbnail_urlstring | nullSigned preview for the order screen, in every status — including paid. It is never the original: the file itself only arrives through source_url, after the buyer approves. Short-lived, so re-read the order rather than caching it
source_urlstring | nullFull-resolution original — null until the item is approved, and null again if the purchase was reversed
delivered_attimestamp | nullWhen the organizer delivered it
approved_attimestamp | nullWhen the purchase was approved
refunded_attimestamp | nullNull on a normal purchase — purchases are final, and this is set only when RunnerGeeks reverses one by hand after a support request
delivery_urlstring | nullDelivery link, once delivered

What checkout guarantees

  • Every cart row lands in a single order — there is no partial checkout and no way to split the cart.
  • Each item's price is written into the order and never recomputed afterwards, so a later price change by the organizer cannot move what the buyer owes.
  • Package pricing is recalculated from scratch here, using each item's from_bulk flag and the seller's config at that moment — so a hand-picked photo is never swept into a package at checkout, and the frozen total can differ from the last quote you showed.
  • The cart is cleared only if the order was created. If checkout fails, the cart is left exactly as it was — retry safely.
  • total at this point is subtotal − discount. The payment-channel fee is added to fee and total only when the buyer picks a payment method.

A rejected coupon rejects the whole checkout — no order is created and the cart is untouched. Validate the code with the buyer still on the cart screen, or let them retry without it.

Error messageStatusWhen
Cart is empty400Nothing to check out — add an item first
{CODE} isn't a valid code.400Unknown coupon_code
{CODE} has expired.400The coupon is past its expiry
Spend {amount} to use {CODE}.400The cart subtotal is below the coupon's minimum
{CODE} has been fully redeemed.409The coupon hit its max_uses limit
One or more photos not found400A cart photo was deleted between the quote and checkout

4.5 Grab all — find every photo of a runner

A runner who finds one photo of themselves almost never wants only that one. Grab all turns a single photo they are already matched to — the seed photo — into the whole set from that event and that seller, priced as one bulk purchase. Both calls are read-only; nothing is reserved, charged or added to the cart here.

Grab-all endpoints
GET /api/partner/v1/photos/{id}/grab-all          metadata only (cheap)
GET /api/partner/v1/photos/{id}/grab-all/photos   the matched set

The split is deliberate. The metadata call does not run the similarity search, so you can render the grab screen — event, seller, what bulk pricing is on offer — immediately, and fetch the photo list into it. Treat the list call as the expensive one.

The seed photo is the access check

Both endpoints answer 404 for a seed photo the caller is not matched to — the same answer a photo id that does not exist gets. One runner therefore cannot enumerate another runner's set by guessing ids, and your app cannot read a 404 as “the photo is gone”. Seed from an id that came back in that user's own search results.

A seed photo that is not for sale, or whose seller has no bulk-pricing config for the event, is also a 404: there is no set to grab.

Grab-all metadata

GET /photos/{id}/grab-all user token

Metadata for the grab screen: the event, the seller and the bulk-pricing config behind the seed photo. Deliberately does not run the similarity search, so it is cheap enough to render a screen with.

Request
GET /api/partner/v1/photos/9ad3f201-6c7b-4e55-91af-0d2e85b3c714/grab-all
Authorization: Bearer <enduser_token>

{id} is the seed photo. Response — 200 OK:

Response · 200
{
  "sample_url": "https://img.runnergeeks.id/image/<sig>/9ad3f201-…?expires=1760000000",
  "event": {
    "id": "3f51c8de-…",
    "name": "Merbabu Trail Run 2026",
    "thumbnail_url": "https://img.runnergeeks.id/…"
  },
  "owner": {
    "id": "6c0a98b2-…",
    "display_name": "Rimshot ID",
    "photo_url": "https://img.runnergeeks.id/…",
    "username": "rimshotid"
  },
  "bulk": {
    "event_id": "3f51c8de-…",
    "seller_user_id": "6c0a98b2-…",
    "mode": "tier",
    "flat_price": null,
    "tiers": [
      { "min_photos": 5, "discount_pct": "10" },
      { "min_photos": 10, "discount_pct": "20" }
    ]
  }
}
data fieldsTypeWhat it's for
sample_urlstringSigned, time-limited preview of the seed photo itself, for the header of the grab screen. It carries an expires timestamp — render it, never cache it.
event{ id, name, thumbnail_url }Event the seed photo belongs to. thumbnail_url can be null.
owner{ id, display_name, photo_url, username }Seller of the set — the photographer or organizer the whole grab is bought from. photo_url and username can be null.
bulkobjectThe bulk-pricing config for this (event, seller) pair. Always present: a seed photo without one is a 404, because there is nothing to grab in bulk.
bulk fieldsTypeWhat it's for
event_iduuidEvent the config belongs to.
seller_user_iduuidSeller the config belongs to — the same id as owner.id.
modeflat | tierflat = one package price for the whole set; tier = percentage discounts that unlock at photo counts.
flat_pricedecimal (string) | nullPackage price when mode is flat. Null in tier mode. A JSON string — parse it as a decimal, never a float.
tiersarray | nullWhen mode is tier: [{ min_photos, discount_pct }] steps, omitted entirely when unset. Show them as what the runner unlocks; never price the cart yourself.

Use bulk to tell the runner what the whole set is worth before you fetch it — “grab 10 and save 20%”. It is a description of the offer, not a quote: the number the buyer owes is always the one GET /cart and then checkout return.

The matched set

GET /photos/{id}/grab-all/photos user token

The photos the runner is matched to, found by similarity from the seed photo. Capped at 200 results — the same ceiling POST /cart/items/batch accepts, so one grab-all set is always one batch call.

Request
GET /api/partner/v1/photos/9ad3f201-6c7b-4e55-91af-0d2e85b3c714/grab-all/photos?threshold=0.4
Authorization: Bearer <enduser_token>
Query parametersTypeWhat it's for
thresholdfloat · 0.3–1.0 · default 0.4Minimum similarity a photo must reach to join the set. Lower widens the set and lets in lookalikes; higher narrows it to near-certain matches. Outside the range it is rejected with “Threshold must be between 0.3 and 1.0”.

Response — 200 OK:

Response · 200
{
  "photos": [
    {
      "photo_id": "7dfa8f07-…",
      "face_id": "b1c47e90-…",
      "event_id": "3f51c8de-…",
      "event_name": "Merbabu Trail Run 2026",
      "album_name": "27. [MTR26] Perkampungan KM 5.5",
      "score": 0.71,
      "final_score": 0.74,
      "is_for_sale": true,
      "signed_url": "https://img.runnergeeks.id/image/<sig>/7dfa8f07-…?expires=1760000000",
      "preview_url": "",
      "effective_price": 14000.0,
      "price": 14000.0,
      "currency": "IDR",
      "owner": { "id": "6c0a98b2-…", "display_name": "Rimshot ID" },
      "angle_support": 2
    }
  ],
  "total": 37
}
data fieldsTypeWhat it's for
photosarrayThe matched set, in the order the search ranked it. Same match shape the face-search results use — render it, do not re-sort it.
totalintSize of the returned set. There is no paging: the set is the response.
photos[] fieldsTypeWhat it's for
photo_iduuidThe id you collect and post to /cart/items/batch. This is the only field the grab flow strictly needs.
face_iduuidThe face inside the photo that matched.
score / final_scorefloatRaw cosine similarity and the ranked score. Useful for a “best match” badge; the cut-off is threshold, not something you re-apply.
is_for_saleboolWhether the photo can be bought at all. A false here is exactly the kind of row that comes back in skipped[] if you post it anyway.
signed_urlstring | nullWatermarked preview, signed and expiring. Present only while the photo is for sale.
preview_urlstringReserved — currently always an empty string. Render the photo from signed_url (on sale) or source_url, not from this field.
effective_price / pricenumberPer-photo list price before any bulk discount. The real total comes from GET /cart once the batch is in — never add these up yourself.
currencystring | nullISO code, e.g. IDR.
ownerobject | nullSeller of this photo. Matches owner from the metadata call — a grab-all set is always one seller, one event.
angle_supportint | nullHow many reference angles of the runner's face this photo matched. Null when the match ran a single angle.

Threshold, in practice

Leave it at the default and the set is what the runner expects. Expose it only if you have a reason to: dropping towards 0.3 pulls in people who merely look alike, and the runner pays for them. Raising it towards 1.0 loses genuine photos shot from an awkward angle.

The set is capped at 200 photos, the same ceiling the batch add accepts, so a full grab is always one POST /cart/items/batch. An empty photos array with total: 0 is a valid 200 — show “nothing else found”, not an error.

From seed photo to paid order

Five calls, in this order. Steps 1 and 2 are the only grab-specific ones — everything after is the ordinary cart path. What makes the package price apply is that the batch add marks these photos from_bulk (its default), and the cart then returns them as one group. Photos the runner adds on their own keep their per-photo price.

Grab-all flow
1  GET  /photos/{seed_photo_id}/grab-all          -> event, owner, bulk tiers
2  GET  /photos/{seed_photo_id}/grab-all/photos   -> photos[].photo_id
3  POST /cart/items/batch                         -> added[] / skipped[]   (from_bulk: true)
4  GET  /cart                                     -> groups[] = the package, priced
5  POST /cart/checkout                            -> order, prices frozen

Collect photos[].photo_id and post them in one call to POST /cart/items/batch. It reports every id individually and still returns 200, so a single unsellable photo cannot cost the runner the other forty. Then read GET /cart once — the photos come back as one entry in groups[] with the package price and what it saves, if anything — and POST /cart/checkout to freeze it into an order.

4.6 Orders & payment

An order is the frozen record of a purchase: item prices are copied into it at creation and never recalculated. Two ways in — cart checkout for a basket the user built up, or POST /orders below to buy a known set of photos in one call. Everything after that (pay, poll, cancel) is the same for both.

All money fields are serialised as JSON strings ("27700.00") — parse them as decimals, never as floats. Every response below is the standard envelope; only the data payload is shown.

Status lifecycle

An order carries three independent statuses. Drive your UI from payment_status while the buyer is paying, and from delivery_status afterwards.

Transitions
status            pending ──pay──▶ awaiting_payment ──settled──▶ paid ──▶ refunded*
                     │                   │
                     └───cancel──────────┴──failed / expired──▶ cancelled

payment_status    pending ──pay──▶ waiting_payment ──▶ processing ──▶ paid ──▶ refunded*
                  │                       │
                  └───cancel──────────────┴──▶ cancelled
                                          └──▶ failed / expired

delivery_status   pending ──▶ partial ──▶ delivered   (after payment, seller-driven)

* refunded is an exception RunnerGeeks handles by hand, never a step your app
  drives — see "Refunds" at the end of this section.
statusSet whenMeaning
pendingon createOrder exists, no payment attempt yet. Payable and cancellable.
awaiting_paymenton POST /payA checkout instruction has been issued. Still payable (the buyer may switch method) and still cancellable.
paidpayment settledTerminal for the purchase. Revenue split written; delivery takes over from here.
cancelledcancel / failed / expiredTerminal. Set by PATCH /cancel, by a failed payment, or by the expiry job after the ~24h payment window. payment_status is settled in the same step: cancelled, failed or expired respectively.
refundedexception, set by RunnerGeeksTerminal. Purchases are final, so this is never something your app asks for — it appears only after RunnerGeeks reverses a purchase by hand (see Refunds). Follows payment_status refunded; a REFUND ledger entry is written.
payment_statusKindMeaning
pendingnon-terminalDatabase default — no payment attempt has been made yet. Polling payment-status now returns 400 (no payment row).
waiting_paymentnon-terminalCheckout issued, waiting for the buyer. This is also the fallback for any payment state that is not one of the four below.
processingnon-terminalThe payment was accepted but has not settled yet (e.g. a bank transfer under review). Keep polling — do not unlock photos.
paidterminalSettled. Order status flips to paid, splits are created, and every non-refunded item's source_url is available from this point on.
failedterminalThe payment was rejected, or the buyer abandoned it; order status becomes cancelled. The buyer must create a new order.
expiredterminalPayment window elapsed (expiry job). Order status becomes cancelled.
cancelledterminalThe buyer cancelled the order with PATCH /cancel. Written together with status cancelled, so a client that only reads payment_status stops offering to pay or cancel it.
refundedterminal, exceptionalA purchase RunnerGeeks reversed after a manual review — there is no API that produces it (see Refunds). Order status becomes refunded and the affected items carry refunded_at.
delivery_statusSet whenMeaning
pendingdefaultNothing delivered yet.
partialafter a deliverySome items have delivered_at, others are still outstanding.
deliveredafter a deliveryEvery non-refunded item has been delivered. The buyer can still approve or reject each one; neither changes whether source_url is available.

Delivery does not finish on its own

A delivered item waits on the buyer. Approving it confirms the delivered file is right; the original file is already in source_url from the moment the order is paid. Rejecting it sends that item back to pending so the seller can deliver a fresh link. Both are the buyer's own calls — POST /orders/{id}/items/{item_id}/approve and the matching reject, documented with the purchased photos. Rejecting is not a refund: no money moves, it only asks for re-delivery.

Treat these as open sets. The list filter also accepts completed as a synonym of paid, and cancelled as a payment status alongside expired. Match on the values you know and fall back to "in progress" for anything else rather than crashing on an unknown string.

Create an order

POST /orders user token

Buy a known set of photos without going through the cart. Prices are resolved and frozen inside one transaction, so the returned order is final — nothing re-prices later.

Request
POST /api/partner/v1/orders
Authorization: Bearer <enduser_token>
Content-Type: application/json

{
  "items": [
    { "photo_id": "7dfa8f07-1f2a-4c55-9a01-3b7e51cd9a10", "bulk": true },
    { "photo_id": "9b21c4e0-88d3-4a17-b0c2-1d5f6a7e2b44" }
  ],
  "coupon_code": "RUNNER10",
  "notes": "Order from Partner App"
}
Request bodyTypeWhat it's for
items[]array · requiredAt least one entry — an empty array is a 400.
items[].photo_iduuid · requiredMust exist, must be is_for_sale, and must not be owned by the buyer (self-purchase → 400).
items[].bulkbool · optionalWhether the photo is priced as part of its event's bulk package. true → eligible for the package price; false → priced per photo, even if the cart holds it as a bulk row. Omitted → follows the caller's cart: a photo that is in the cart with from_bulk true is bulk, anything else is per photo. Send it explicitly when you create an order from a runner's selection, so what they were quoted is what they are charged.
coupon_codestring · optionalCase-insensitive. Invalid/expired/below-minimum → 400; fully redeemed → 409.
notesstring · optionalFree text echoed back on the order.

Response — 200 OK:

Response · 200
{
  "data": {
    "id": "c2f1a9d4-5e6b-4c7a-8d90-1f2e3a4b5c6d",
    "order_number": "RG-9F3A21B7C4D0",
    "subtotal": "28000.00",
    "discount": "2800.00",
    "tax": "0.00",
    "fee": "0.00",
    "total": "25200.00",
    "currency": "IDR",
    "status": "pending",
    "payment_status": "pending",
    "delivery_status": "pending",
    "items": [
      {
        "id": "a81b0c37-2d4e-4f50-91a2-b3c4d5e6f708",
        "photo_id": "7dfa8f07-1f2a-4c55-9a01-3b7e51cd9a10",
        "album_id": "47ac574d-0b1c-4d2e-8f30-a1b2c3d4e5f6",
        "event_id": "444bba10-9c8d-4e7f-b6a5-04132536475e",
        "event_name": "Merbabu Trail Run 2026",
        "album_name": "27. [MTR26] Perkampungan KM 5.5",
        "owner": {
          "id": "0f5c1d3a-aa11-4b22-9c33-d44e55f66a77",
          "display_name": "Rimshot ID",
          "photo_url": null,
          "username": null
        },
        "price": "14000.00",
        "currency": "IDR",
        "thumbnail_url": "https://img.runnergeeks.id/thumbnail/<sig>/150x200/7dfa8f07-…?expires=1760000000",
        "source_url": null,
        "delivered_at": null,
        "approved_at": null,
        "refunded_at": null,
        "delivery_url": null
      }
    ],
    "payment": null,
    "notes": "Order from Partner App",
    "buyer": null,
    "created_at": "2026-10-14T03:11:08Z",
    "updated_at": "2026-10-14T03:11:08Z"
  }
}

400 covers an empty items array, an unknown photo, a photo that is not for sale, a photo the buyer owns (self-purchase is blocked because the revenue split would credit the buyer), and an invalid or below-minimum coupon. A coupon that has run out of uses is 409.

List the user's orders

GET /orders user token

The signed-in user's own order history, newest first (ordered in SQL — do not re-sort client-side). Filtering and search are applied in SQL, so total always matches the filter.

Request
GET /api/partner/v1/orders?page=1&per_page=20&status=waiting&search=merbabu
Authorization: Bearer <enduser_token>
Query paramsTypeWhat it's for
pageinteger1-based. Default 1; values below 1 are clamped to 1.
per_pageintegerDefault 20, capped at 500.
statusstringFilter bucket over payment_status — paid, waiting, failed or cancelled. Any other value is ignored (no filter).
searchstringCase-insensitive substring over order_number, event name and album name. Matched in SQL, so total stays correct.
statusKindMatches
paidbucketpayment_status in paid, completed
waitingbucketpayment_status in waiting_payment, pending, awaiting_payment, processing
failedbucketpayment_status = failed
cancelledbucketpayment_status in cancelled, expired

Response — 200 OK:

Response · 200
{
  "data": {
    "items": [
      {
        "id": "c2f1a9d4-…",
        "order_number": "RG-9F3A21B7C4D0",
        "subtotal": "28000.00",
        "discount": "2800.00",
        "tax": "0.00",
        "fee": "2500.00",
        "total": "27700.00",
        "currency": "IDR",
        "status": "awaiting_payment",
        "payment_status": "waiting_payment",
        "delivery_status": "pending",
        "items": [ { "id": "a81b0c37-…", "photo_id": "7dfa8f07-…", "price": "14000.00", "thumbnail_url": "https://img.runnergeeks.id/t/…?exp=…&sig=…", "source_url": null } ],
        "payment": { "id": "6b2e…", "status": "pending", "payment_method": "<method_code>" },
        "notes": null,
        "buyer": null,
        "created_at": "2026-10-14T03:11:08Z",
        "updated_at": "2026-10-14T03:12:44Z"
      }
    ],
    "total": 7,
    "page": 1,
    "per_page": 20,
    "counts": { "all": 7, "paid": 4, "waiting": 1, "failed": 0, "cancelled": 2 }
  }
}
data fieldsTypeWhat it's for
items[]order[]Same object as the detail endpoint, except buyer is always null here.
totalintegerRows matching page + filters, not the page size.
page / per_pageintegerEchoed back.
countsobjectUnfiltered tallies for the whole history: all, paid, waiting, failed, cancelled — the same buckets as the status param, so you can render tabs without extra calls.

Order detail

GET /orders/{id} user token

The full order, including the buyer block and the latest payment attempt. Another user's order is 403, an unknown id is 404.

Request
GET /api/partner/v1/orders/c2f1a9d4-5e6b-4c7a-8d90-1f2e3a4b5c6d
Authorization: Bearer <enduser_token>

Response — 200 OK:

Response · 200
{
  "data": {
    "id": "c2f1a9d4-…",
    "order_number": "RG-9F3A21B7C4D0",
    "subtotal": "28000.00",
    "discount": "2800.00",
    "tax": "0.00",
    "fee": "2500.00",
    "total": "27700.00",
    "currency": "IDR",
    "status": "paid",
    "payment_status": "paid",
    "delivery_status": "partial",
    "items": [ "…" ],
    "payment": {
      "id": "6b2e7f18-3c4d-4e5f-a607-18293a4b5c6d",
      "amount": "27700.00",
      "status": "paid",
      "payment_method": "<method_code>",
      "gateway": "<gateway_code>",
      "snap_token": "66e4fa55-…",
      "snap_redirect_url": "https://checkout.example.com/pay/…",
      "raw_response": { "status": "paid", "expires_at": 1792003600, "instruction": { "kind": "snap", "data": { "…": "…" } }, "invoice_id": null },
      "created_at": "2026-10-14T03:12:44Z"
    },
    "notes": null,
    "buyer": { "id": "8a7b…", "display_name": "Budi Santoso", "email": "budi@example.com" },
    "created_at": "2026-10-14T03:11:08Z",
    "updated_at": "2026-10-14T03:20:02Z"
  }
}
Money fieldsTypeWhat it's for
subtotaldecimal stringSum of the frozen item prices. Never recomputed after creation.
discountdecimal stringCoupon value resolved at creation.
taxdecimal stringAlways 0.00 today — reserved; keep reading it, do not assume it stays zero.
feedecimal stringBuyer-borne payment fee. 0.00 until POST /pay, then set from the chosen method (and re-set if the buyer switches method).
totaldecimal stringsubtotal − discount + fee. Equals the amount charged.
currencystringISO code, IDR in practice. All money fields are JSON strings — parse as decimal, never as float.
data fieldsTypeWhat it's for
iduuidOrder id used in every /orders/{id} route.
order_numberstringHuman reference, RG- + 12 hex characters. It travels with the payment too, so quote it in any support request.
status / payment_status / delivery_statusstringSee the lifecycle tables above.
paymentobject | nullLatest payment attempt (null before the first POST /pay). Carries id, amount, status, payment_method, gateway, snap_token, snap_redirect_url, raw_response and created_at. raw_response holds the persisted checkout — expires_at, instruction, invoice_id — so you can re-render the instruction after an app restart.
buyerobject | nullid, display_name, email of the buyer. Populated on detail only; always null in the list response.
notesstring | nullEchoed from the create call.
created_at / updated_attimestampRFC 3339, UTC.
items[] fieldsTypeWhat it's for
iduuidOrder item id (used by the delivery approve/reject flow).
photo_iduuidThe purchased photo.
album_id / event_iduuid | nullWhere the photo came from; event_name and album_name are the resolved labels (null when the source was deleted).
ownerobject | nullSeller profile — id, display_name, photo_url, username.
pricedecimal stringPrice frozen into the order at creation. For a bulk package the package price is spread across its photos, so the item prices always sum back to subtotal.
thumbnail_urlstring | nullA short-lived signed preview URL, on every endpoint and in every status — create, list, detail, and after payment alike. It is never the original photo: the file itself arrives through source_url, once the order is paid. Render it straight from the response you just read; it expires, so do not store it as the photo's address.
source_urlstring | nullThe full-resolution original. Non-null once the order is paid and the item was not refunded — approval is not required. Null on an unpaid order, so never treat its absence as an error.
delivered_at / approved_at / refunded_attimestamp | nullDelivery milestones for this item; they drive delivery_status. refunded_at is null on a normal purchase — it is set only when RunnerGeeks reverses one by hand (see Refunds).
delivery_urlstring | nullSeller-supplied delivery link for review, present once the item is delivered.

List payment methods

GET /payments/methods user token

The methods this instance can actually charge, straight from the database. Read it at checkout and render the ids you get back — the list changes when an admin enables or retires a method, and paying with a code that is not on it is a 400.

Request
GET /api/partner/v1/payments/methods
Authorization: Bearer <enduser_token>

Response — 200 OK:

Response · 200
{
  "success": true,
  "data": [
    {
      "id": "<method_code>",
      "label": "BNI Virtual Account",
      "desc": "Transfer from any bank to a BNI virtual account",
      "type": "bank_transfer",
      "adminFee": { "amount": 4000, "type": "fixed" },
      "adminFeeBorneByCustomer": true,
      "channelFeeBorneByCustomer": false,
      "icon": "landmark"
    }
  ]
}

Group the list by type for the picker: bank_transfer, ewallet, credit_card, snap, offline_outlet. The fee fields are there so you can preview the amount the buyer will be charged; the backend stays authoritative and recomputes it at pay time.

Pay for an order

POST /orders/{id}/pay user token

Issues a checkout instruction for the chosen method. Only pending / awaiting_payment orders are payable — anything else is 400 'Order no longer payable'.

Request
POST /api/partner/v1/orders/c2f1a9d4-…/pay
Authorization: Bearer <enduser_token>
Content-Type: application/json

{
  "payment_method": "<method_code>",
  "phone": "081234567890"
}
Request bodyTypeWhat it's for
payment_methodstring · requiredMethod code, minimum 3 characters — an `id` from GET /payments/methods. Read that list at checkout rather than hard-coding one: an admin can enable or retire a method at any time, and an unknown or unconfigured code is a 400.
token_idstring · optionalInstrument token, for methods that tokenise the card or wallet before the charge. Opaque — pass through whatever the checkout step handed you.
phonestring · optionalBuyer phone — required in practice by several e-wallet methods, so send it whenever you have it.

Response — 200 OK:

Response · 200
{
  "data": {
    "payment_id": "6b2e7f18-3c4d-4e5f-a607-18293a4b5c6d",
    "order_id": "c2f1a9d4-…",
    "amount": "27700",
    "payment_method": "<method_code>",
    "provider": "<route_code>",
    "checkout": {
      "reference": "RG-9F3A21B7C4D0",
      "expires_at": 1792003600,
      "instruction": {
        "kind": "snap",
        "data": {
          "token": "66e4fa55-1b2c-4d3e-8f90-a1b2c3d4e5f6",
          "redirect_url": "https://checkout.example.com/pay/…"
        }
      },
      "invoice_id": null
    }
  }
}
data fieldsTypeWhat it's for
payment_iduuidThe payment row this attempt wrote.
order_iduuidOrder being paid.
amountdecimal stringWhat the buyer is charged — subtotal − discount + buyer-borne fee, rounded to whole currency units. The order's fee and total are updated to match.
payment_methodstringEcho of the requested method code.
providerstringIdentifier of the payment gateway RunnerGeeks routed this payment through. Log it for support; do not branch on it or show it to the buyer — the set of gateways changes without notice.
checkout.referencestringReference RunnerGeeks uses for status checks on this checkout. Its shape varies by method — treat it as an opaque string and quote it in support requests.
checkout.expires_atinteger | nullUnix seconds when this checkout expires. null = this method sets no expiry of its own.
checkout.invoice_idstring | nullInternal object id reused when the buyer switches method. Opaque, and null for methods that have no update step.
checkout.instructionobjectTagged union — { "kind": …, "data": { … } }. Switch on kind and render exactly one instruction.
instruction.kindinstruction.dataWhat to render
snaptoken, redirect_urlHosted checkout. Open redirect_url in a browser or in-app web view, or hand token to the checkout SDK you were given at onboarding.
vanumber, bankShow number and bank to the buyer so they can transfer the exact amount, then wait for the webhook.
qrpayload?, action_url?Render the QR for the buyer to scan — show action_url as an image, or encode payload yourself. Either may be null, so check both.
chargepay_url?Hosted charge or deeplink. Open pay_url when it is present; null means the buyer finishes in the app they already picked, so just show the amount and keep polling.
redirectredirect_urlSend the buyer to redirect_url and bring them back to your order screen afterwards.
embeddedlinkOpen link in an in-app web view (or an iframe on web) and keep the buyer inside your app.

Switching method is safe. Calling pay again on an unpaid order releases or updates the previous attempt and returns a fresh instruction, so let the buyer change their mind. The fee is recalculated for the new method and the order's fee / total are rewritten — re-read the order (or use the returned amount) instead of caching the old total. Render exactly one instruction at a time.

Check payment status

GET /orders/{id}/payment-status user token

Re-checks the payment and persists any terminal result (splits, notifications and the outbound webhook all fire from here). Idempotent — repeated calls after settlement change nothing.

Request
GET /api/partner/v1/orders/c2f1a9d4-…/payment-status
Authorization: Bearer <enduser_token>
Response · 200
{
  "data": {
    "status": "paid",
    "payment_status": "paid"
  }
}
data fieldsTypeWhat it's for
statusstringOrder status after the check — pending, awaiting_payment, paid, cancelled or refunded.
payment_statusstringPayment status after the check — pending, waiting_payment, processing, paid, failed, expired or refunded.

A 400 here is not a failed payment: it means there is nothing to poll yet — no payment row (the order was never paid) or no payment reference yet (e.g. a hosted checkout the buyer has not opened). Keep the order in its current state and retry.

Webhooks first, polling as a fallback. The payment_status_changed webhook is the primary signal — it fires on every terminal transition and on expiry, with no app-side timer. Poll this endpoint only while the buyer is sitting on the payment screen, or when no webhook endpoint is registered: every 3–5 seconds, for at most about 2 minutes, then stop and wait for the webhook. The order's own window is a flat 24 hours from the first pay call — an expiry sweep runs every few minutes and only then marks the order expired. So an order still waiting_payment when you stop polling is not lost, and checkout.expires_at (the checkout's own expiry) does not decide when the order dies.

Cancel an order

PATCH /orders/{id}/cancel user token

Allowed while status is pending or awaiting_payment. Idempotent: cancelling an already-cancelled order is a 200 no-op, so a retry or a stale screen is safe. A paid order is 400 'Paid orders cannot be cancelled'; any other state is 400 'Only pending orders can be cancelled'. Another user's order is 403.

Request
PATCH /api/partner/v1/orders/c2f1a9d4-…/cancel
Authorization: Bearer <enduser_token>
Response · 200
{
  "code": 0,
  "success": true,
  "message": null,
  "data": null
}

There is no payload — the envelope's data is null. Cancelling sets status to cancelled and payment_status to cancelled together, so neither field keeps offering the order for payment. Repeating the call is harmless — it answers 200 again — and it also settles an order cancelled before the two were written together. It is a local state change on an unpaid order: no money has moved, so there is nothing to return, and a checkout the buyer already opened should be dismissed in your UI.

Refunds

Photos are digital products delivered as soon as payment is confirmed, so purchases are final and non-refundable under the RunnerGeeks Terms. There is no refund endpoint, and nothing in this API lets your app request, trigger or promise one — do not offer a refund anywhere in your UI.

The exception is a purchase that failed technically or that the buyer says was never authorised. That is a support matter: the buyer emails billing@runnergeeks.id with the order_number, and RunnerGeeks reviews and processes it by hand.

Still handle the value. When a reversal does happen, the order reads back with status and payment_status as refunded, the affected items carry refunded_at, and their delivery_url / source_url go back to null. Render that order read-only and drop any file you cached for it.

Money note: RunnerGeeks collects every payment and splits the revenue to the organizer automatically. Your app never sees, holds or forwards payment credentials — it renders the instruction this API returns and reports what the buyer did, nothing more.

4.7 Favorites

A favorite is a user's private bookmark on one photo or one event. Three calls, all with the end-user's token: toggle one, list them by type, and read the two counters for a badge.

Endpoints
POST /api/partner/v1/favorites          toggle one photo or event
GET  /api/partner/v1/favorites          list (item_type=photo|event)
GET  /api/partner/v1/favorites/counts   how many of each type

Not tenant-scoped. Unlike event reads and GET /purchases, favorites are keyed on the user alone: the list and the counts cover everything this person has favorited on RunnerGeeks, including items from organizers that are not yours. Filter by event_id on your side if your app should only show its own.

Toggle a favorite

POST /favorites user token

One endpoint for both directions. The body is { item_type, item_id } — not { photo_id }. Sending the same pair twice un-favorites it, so send one call per tap and trust the returned state rather than tracking it locally.

Request
POST /api/partner/v1/favorites
Authorization: Bearer <enduser_token>
Content-Type: application/json

{ "item_type": "photo", "item_id": "9ad3f201-…" }
Request bodyTypeWhat it's for
item_typestring — requiredphoto or event. Anything else is rejected with 400 — there is no third kind of favorite.
item_iduuid — requiredThe photo id or the event id, matching item_type.
Response · 200
{
  "code": 0,
  "success": true,
  "message": null,
  "data": {
    "favorited": true,
    "item_type": "photo",
    "item_id": "9ad3f201-…"
  }
}
data fieldsTypeWhat it's for
favoritedboolState after the call: true = now favorited, false = the favorite was removed.
item_typestringEchoed back from the body.
item_iduuidEchoed back from the body.

It is a toggle, not a set. There is no separate "unfavorite" call and no idempotent "add" — favorited in the response is the new state, so render from it. Retrying a call that already succeeded will flip the favorite back.

List favorites

GET /favorites user token

One type per call, newest first. The response envelope is { items, total, page, per_page }; the item shape differs between the two types.

Request
GET /api/partner/v1/favorites?item_type=photo
Authorization: Bearer <enduser_token>
Query parametersTypeWhat it's for
item_typephoto | event — default eventWhich collection to read. Omitting it lists favorited events, not photos — pass item_type=photo explicitly for the photo list.

No paging here. The partner surface forwards only item_type, so page and per_page are ignored and you always get the first 20. Read total to know whether more exist.

Response · 200 · item_type=photo
{
  "code": 0,
  "success": true,
  "message": null,
  "data": {
    "items": [
      {
        "id": "b41c0de7-…",
        "item_type": "photo",
        "item_id": "9ad3f201-…",
        "created_at": "2026-10-04T09:12:44Z",
        "source_url": "https://imager.runnergeeks.id/image/<sig>/9ad3f201-…?expires=1760000000",
        "signed_url": "https://imager.runnergeeks.id/image/<sig>/9ad3f201-…?expires=1760000000",
        "width": 4000,
        "height": 6000,
        "is_for_sale": true,
        "price_override": null,
        "effective_price": "35000.00",
        "currency": "IDR",
        "event_id": "3f51c8de-…",
        "event_name": "Jakarta Marathon 2026",
        "album_name": "KM 32 — Sudirman"
      }
    ],
    "total": 12,
    "page": 1,
    "per_page": 20
  }
}
data fieldsTypeWhat it's for
itemsarrayFavorites of that type, newest first. Shape depends on item_type (below).
totalintAll favorites of this type for the user, not just this page.
pageintAlways 1 on the partner surface.
per_pageintAlways 20 on the partner surface.
items[] · item_type=photoTypeWhat it's for
iduuidId of the favorite row itself.
item_typestringAlways photo here.
item_iduuidThe favorited photo.
created_atdatetimeWhen the user favorited it. Orders the list, newest first.
source_urlstringFor a photo that is for sale this is the signed, expiring imager URL (the same value as signed_url). For a photo not for sale it is the stored source URL as indexed.
signed_urlstringSigned, time-limited imager URL, valid until its expires timestamp. Present only when is_for_sale is true, so treat it as optional.
widthint | nullPixel width of the photo, when known.
heightint | nullPixel height of the photo, when known.
is_for_saleboolWhether the photo is currently purchasable.
price_overridedecimal | nullPer-photo price, when the organizer set one.
effective_pricedecimalWhat the buyer would pay: price_override, else the event's base price, else 0.
currencystringThe event's currency, defaulting to IDR.
event_iduuid | nullEvent the photo belongs to.
event_namestring | nullEvent name.
album_namestring | nullAlbum name.
items[] · item_type=eventTypeWhat it's for
iduuidId of the favorite row itself.
item_typestringAlways event here.
item_iduuidThe favorited event.
source_urlstringAlways an empty string for events — the field exists for shape parity with photos, so ignore it and read the event through GET /events.
created_atdatetimeWhen the user favorited it. The list is ordered by this, newest first.

Favorite counts

GET /favorites/counts user token

Two totals for the signed-in user — one per item type. Cheap enough to call for a tab badge instead of paging the lists.

Response · 200
{
  "code": 0,
  "success": true,
  "message": null,
  "data": { "events": 3, "photos": 12 }
}
data fieldsTypeWhat it's for
eventsintNumber of events this user has favorited.
photosintNumber of photos this user has favorited.

4.8 Purchased photos

Every photo this end-user has already bought across your events — the "my photos" library your app opens after checkout. Read-only, and paid items only: an order that was never paid never shows up here.

Tenant-scoped: results are restricted to events owned by the organizer(s) behind the partner client this user was synced through. A partner app never learns what the runner bought from a different organizer. That is why this is not a mirror of the main GET /api/users/me/purchases, which is unscoped — the two share an item shape, not a result set, so total here counts only what is in your scope.

Request

GET /purchases user token

Paginated list of the caller's purchased photos within your organizer scope, newest first unless sort says otherwise. All filtering, ordering and counting happen server-side — do not re-sort or re-filter the page in the app.

Request
GET /api/partner/v1/purchases?page=1&per_page=20&sort=recent
Authorization: Bearer <enduser_token>
Query parametersTypeWhat it's for
pageint — default 11-based page number. Values below 1 are raised to 1.
per_pageint — default 20Items per page, clamped to 1–100 on this endpoint.
searchstringCase-insensitive substring over the event name, the album name, or the photo_id written as text.
eventstringExact event name or album name. The literal "All events" is treated as no filter.
sortrecent | event | pricerecent (the default, and what any unknown value falls back to) = newest order item first; event = event name then album name, A→Z; price = highest price first.
delivery_statuspending | awaiting_approval | delivered | refundedServer-side filter on delivery state. An unrecognised value is ignored and returns the unfiltered page.

Response

Response · 200
{
  "code": 0,
  "success": true,
  "message": null,
  "data": {
    "items": [
      {
        "order_id": "7b2f9c10-…",
        "item_id": "1c4e77aa-…",
        "photo_id": "9ad3f201-…",
        "event": { "id": "3f51c8de-…", "name": "Jakarta Marathon 2026" },
        "album": { "id": "a07be412-…", "name": "KM 32 — Sudirman" },
        "price": "35000.00",
        "currency": "IDR",
        "delivery_status": "delivered",
        "thumbnail_url": "https://imager.runnergeeks.id/thumbnail/<sig>/150x200/9ad3f201-…?expires=1760000000",
        "delivered_at": "2026-10-02T04:11:08Z",
        "approved_at": "2026-10-02T06:40:55Z",
        "rejected_at": null,
        "refunded_at": null,
        "delivery_url": "https://cdn.runnergeeks.id/delivery/…",
        "source_url": "https://cdn.runnergeeks.id/original/…"
      }
    ],
    "total": 42,
    "page": 1,
    "per_page": 20
  }
}
data fieldsTypeWhat it's for
itemsarrayThis page of purchased items, in the order the server returned them.
totalintMatching items across all pages, counted with the same filters and the same tenant scope.
pageintPage echoed back.
per_pageintPage size echoed back.
items[] fieldsTypeWhat it's for
order_iduuidOrder the item was bought in.
item_iduuidOrder-item id — the handle for approve/reject calls.
photo_iduuidPhoto that was purchased.
event{ id, name }Event the photo belongs to. Both members can be null for an item whose event was removed.
album{ id, name }Album the photo belongs to. Both members can be null.
pricedecimal (string)Price actually paid for this item, not the current list price.
currencystringISO code, e.g. IDR.
delivery_statusstringDerived state — one of the four values below.
thumbnail_urlstringSigned 150×200 preview. Always present, and expires — read it fresh, never cache it.
delivered_atdatetime | nullWhen the organizer delivered the file.
approved_atdatetime | nullWhen the buyer approved the delivery.
rejected_atdatetime | nullWhen the buyer rejected a delivery. Exposed on its own — there is no "rejected" delivery_status, and a rejected item reads as pending again.
refunded_atdatetime | nullSet only when RunnerGeeks reversed the purchase by hand — null on a normal item. See the note below.
delivery_urlstring | nullDelivered file, for review. Null until delivered, and null again if the purchase was reversed.
source_urlstring | nullOriginal file. Available as soon as the order is paid — approval is not required — and null if the purchase was reversed.

Delivery states

delivery_status is derived from the four timestamps on the item, so it is always consistent with them. These are the only values the API emits:

pending
Paid, nothing delivered yet — or a delivery the buyer rejected. source_url is already available (payment unlocks it); delivery_url is not, until the seller delivers.
awaiting_approval
Delivered and waiting on the buyer. delivery_url is available so they can review the file before approving.
delivered
The buyer approved the delivered file. Approval records that acceptance; it does not unlock anything, since source_url is available from paid onward.
refunded
A purchase RunnerGeeks reversed by hand — an exception, never something your app can ask for. It outranks every other state, and both delivery_url and source_url go back to null.

Two edges worth coding for. A buyer who rejected a delivery drops back to pending, because rejecting clears delivered_at — check rejected_at to tell a refused delivery from one that never arrived. And delivery_status=delivered filters on "the buyer approved", so an item that was approved and later reversed matches that filter while reporting "refunded"; add refunded_at === null if you want approved-and-still-valid only.

About refunded: photos are digital products delivered immediately, so purchases are final and non-refundable under the RunnerGeeks Terms. There is no refund endpoint and no way for your app to request, offer or promise one. The value exists because a purchase that failed technically or was never authorised can be reversed by hand after a support request to billing@runnergeeks.id — so read it, render it read-only, and never surface it as an action.

When each URL is available

The three URLs unlock at different points in the delivery flow, so render from the field itself rather than from delivery_status — a null means "not yours yet", not "missing".

URLPresent whenWhat it is
thumbnail_urlalwaysSigned, time-limited preview of the photo. Present in every state, including a reversed purchase.
delivery_urldelivered_at set, refunded_at nullThe delivered file, exposed so the buyer can review it before approving. States awaiting_approval and delivered.
source_urlorder paid, refunded_at nullThe original file. Available from the moment the order is paid, in every delivery state — the buyer does not have to approve first.
  • Thumbnails expire. thumbnail_url is signed with an expires timestamp — store the item, not the link, and re-read the list when a cached link stops loading.
  • Payment is the unlock. Once an order is paid, the original file is in source_url whatever the delivery state; an order that is not paid never exposes it, and a reversed purchase takes it away again.

Approve or reject a delivery

These two calls are how an item leaves awaiting_approval: approving says the delivered file is right, rejecting asks the seller to send it again. Neither is needed to get the original — that is in source_url as soon as the order is paid — so build them as the buyer's quality check on the delivery, not as a step the purchase depends on.

Delivery loop
pending ──(seller delivers)──▶ awaiting_approval
                                      │
                                      ├──(approve)──▶ delivered   buyer accepted the file
                                      │
                                      └──(reject)───▶ pending     seller delivers again

Both take the end-user token of the buyer, and both return the updated order item. Approving sets approved_at and clears rejected_at; rejecting clears delivered_at, delivery_url and approved_at, sets rejected_at, and notifies the seller to send a fresh link. Either call can follow the other — a runner who rejected by mistake can approve the next delivery, and an approval is not a dead end for a later re-delivery.

Reject is not a refund

No money moves. Purchases are final and non-refundable under the RunnerGeeks Terms, and rejecting only asks the seller to deliver the file again — the item goes back to pending, still paid for. Label the button Ask for a new file, never Refund or Cancel, or your runners will expect money back that the platform will not send.

Approve an item

POST /orders/{id}/items/{item_id}/approve user token

The buyer confirms the delivered file is correct. Sets approved_at and clears rejected_at. It records acceptance; it does not gate source_url, which payment already unlocked. No request body.

Request
POST /api/partner/v1/orders/7b2f9c10-…/items/1c4e77aa-…/approve
Authorization: Bearer <enduser_token>

{id} is the order, {item_id} the item_id from the purchases list. Response — 200 OK, the updated item:

Response · 200
{
  "id": "1c4e77aa-…",
  "order_id": "7b2f9c10-…",
  "photo_id": "9ad3f201-…",
  "album_id": "a07be412-…",
  "event_id": "3f51c8de-…",
  "event_name": "Jakarta Marathon 2026",
  "album_name": "KM 32 — Sudirman",
  "owner_id": null,
  "owner_display_name": null,
  "owner_photo_url": null,
  "owner_username": null,
  "price": "35000.00",
  "currency": "IDR",
  "delivered_at": "2026-10-02T04:11:08Z",
  "refunded_at": null,
  "approved_at": "2026-10-02T06:40:55Z",
  "rejected_at": null,
  "delivery_url": "https://cdn.runnergeeks.id/delivery/…",
  "source_url": "https://cdn.runnergeeks.id/original/…"
}
data fieldsTypeWhat it's for
id / order_id / photo_iduuidThe item, its order and its photo. id here is the same value the purchases list calls item_id.
event_id / event_name / album_id / album_nameuuid | string | nullWhere the photo sits. Null when the event or album was removed.
owner_id / owner_display_name / owner_photo_url / owner_usernamenullAlways null on this response — the seller is not re-resolved here. Read the seller from the purchases list instead.
price / currencydecimal (string) · stringWhat was paid for this item. Approving or rejecting never changes it.
delivered_atdatetime | nullSet when the seller delivered. Cleared back to null by reject.
approved_atdatetime | nullSet by approve, cleared by reject. It records that the buyer accepted the delivery; it does not gate source_url, which payment already unlocked.
rejected_atdatetime | nullSet by reject, cleared by approve — the two are mutually exclusive, so the pair always reads unambiguously.
refunded_atdatetime | nullUntouched by either call. Neither approve nor reject moves money.
delivery_urlstring | nullThe delivered file. Cleared by reject, since that link is exactly what the buyer rejected.
source_urlstring | nullThe stored original on the raw item row. Render the library from GET /purchases, which is the buyer-facing shape — this row is a write receipt.
Error messageStatusWhen
Not your order403The order belongs to a different end-user. Approval is the buyer's act — no partner token, no seller, no admin can stand in for it.
Order item not found404item_id is not in that order, or the order was never paid. Unpaid orders have nothing to approve.

Reject an item

POST /orders/{id}/items/{item_id}/reject user token

The buyer refuses the delivered file. The item returns to pending, the delivery link is dropped, and the seller is notified to deliver again. Same errors as approve.

Request
POST /api/partner/v1/orders/7b2f9c10-…/items/1c4e77aa-…/reject
Authorization: Bearer <enduser_token>
Content-Type: application/json

{ "reason": "Wrong runner — this is not me" }
Request bodyTypeWhat it's for
reasonstring | null · optionalPassed on to the seller with the re-delivery request. An empty body {} is valid, but a reason is what gets the right file back.

Response — 200 OK, the item back at the start of the loop (fields it shares with approve are omitted here):

Response · 200
{
  "id": "1c4e77aa-…",
  "order_id": "7b2f9c10-…",
  "photo_id": "9ad3f201-…",
  "price": "35000.00",
  "currency": "IDR",
  "delivered_at": null,
  "refunded_at": null,
  "approved_at": null,
  "rejected_at": "2026-10-02T07:02:19Z",
  "delivery_url": null,
  "source_url": "https://cdn.runnergeeks.id/original/…"
}

Note what the rejected item reports next time you list it: a cleared delivered_at means its delivery_status is pending again, and rejected_at is the only field that tells you this one was seen and refused rather than never delivered. Surface that difference — it is the runner's own decision, and an app that hides it looks broken.

Webhooks (server → server)

RunnerGeeks pushes event notifications to your registered endpoint. Every delivery is signed with HMAC-SHA256 using the webhook_secret (issued at registration — see § 3.2).

Delivery headersTypeWhat it's for
X-RG-Signatureheadert=<unix_ts>,v1=<hex> — HMAC-SHA256 over "<ts>.<raw_body>"
X-RG-Event-Typeheadere.g. payment_status_changed, handshake.ping
X-RG-Delivery-IdheaderIdempotency key — store it, ignore duplicate deliveries
X-RG-TimestampheaderDelivery time (unix)

Verify on your side:

Node · signature check
import crypto from "node:crypto";

function verifyRgWebhook(req, webhookSecret) {
  // req.headers["x-rg-signature"] === "t=1727400000,v1=<hex>"
  const { t, v1 } = Object.fromEntries(
    req.headers["x-rg-signature"].split(",").map((part) => part.split("=")),
  );

  // 1. Reject replays outside the 5-minute window
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  // 2. Recompute HMAC-SHA256 over "<t>.<raw_body>"
  const digest = crypto
    .createHmac("sha256", webhookSecret)
    .update(`${t}.${req.rawBody}`)
    .digest("hex");

  // 3. Constant-time compare, then store X-RG-Delivery-Id
  return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(v1));
}
  • Retry: a failure (non-2xx / 10s timeout) is retried on this schedule —
Retry schedule
30s -> 2min -> 10min -> 1h -> 6h -> 24h   (6 attempts, then DLQ)
  • After 6 attempts the delivery enters the DLQ (visible in GET /webhooks).
  • Idempotency: store X-RG-Delivery-Id and ignore already-processed deliveries. Reject signatures outside the 5-minute window (replay).
  • Available events: payment_status_changed (payload: order id + new payment status) and handshake.ping.

Error Handling

Every error uses a uniform envelope:

Error envelope
{
  "code": 404,
  "error": true,
  "message": "Not found: Event not found",
  "data": null
}
Envelope fieldsTypeWhat it's for
codeintHTTP status
errorboolAlways true on error
messagestringHuman-readable reason
datanullAlways null on error
HTTP statusTypeWhat it's for
400Bad requestInvalid body/query, empty cart, bib+search_id together — fix the request; show message
401UnauthorizedToken missing/expired — partner: request a new token; user: refresh Firebase
403ForbiddenMissing scope, suspended/revoked client, not a partner end-user, or no Face ID for /search/face-id — do not retry; fix prerequisite
404Not foundDoes not exist or not yours — treat as "not available"
409Conflicte.g. coupon exhausted — show the message
422ValidationField validation failed — fix the field
429Rate limitedExponential backoff
5xxServer errorRetry with backoff; do not treat as a failed transaction without checking status

End-to-End Flow

The recommended flow from user login to delivered photos:

Recommended flow

Step by step

  1. Your server syncs the user

    POST /users/sync (partner token)

    Call it on every app login — it is idempotent and returns user_id + custom_token. The same partner token also lists your events with GET /events.

  2. The app exchanges the custom token

    POST /auth/exchange

    Returns the end-user id_token used for every personal call; renew it with POST /auth/refresh (~hourly).

  3. The user enrols a Face ID

    POST /faces (user token)

    1–5 selfie URLs; embeddings are generated asynchronously — poll GET /faces until has_embedding is true.

  4. The user finds their photos

    POST /search/face-id → GET /photos?event_id=…&search_id=… · or GET /photos?event_id=…&bib=…

    Face search runs on the enrolled Face ID only — there is no selfie-upload endpoint. BIB filtering needs an event with bib_search_support; distance=1 tolerates one OCR digit slip. search_id and bib are mutually exclusive.

  5. Show the grid and previews

    Each item carries effective_price and, for photos on sale, a signed_url preview — and its source_url is replaced by a signed, watermarked URL too, so the listing never hands out the original of a photo that costs money.

  6. The user fills the cart

    POST /cart/items · POST /cart/items/batch · GET /cart

    The cart lives on RunnerGeeks, so it survives a device switch. Photos added through grab all are marked from_bulk and form a package when the event has one; hand-picked photos keep their own price. GET /cart returns the packages already grouped (groups[]) with a signed thumbnail per item — indicative until checkout.

  7. Checkout freezes the order

    POST /cart/checkout

    Returns one order with an order_number; prices are frozen and the cart is cleared.

  8. The user pays

    GET /payments/methods · POST /orders/{id}/pay

    Read the methods the instance can charge right now, let the buyer pick one, and send its id as payment_method. Then follow the returned instruction.kind: snap, va, qr, charge, redirect or embedded. RunnerGeeks collects the payment; your app never holds payment credentials.

  9. Confirm the payment

    webhook payment_status_changed · GET /orders/{id}/payment-status

    The webhook to your server is the primary signal; polling every 3–5 s (max ~2 min) is the fallback when the app cannot wait for it.

  10. The original is unlocked

    Once the order is paid, each item's original source_url is available on order detail and in the purchases library — no extra call from your side. RunnerGeeks also delivers the files; approve and reject are the buyer's quality check on that delivery, not a step the original depends on.

Example integration on GitHub A working partner backend, a Flutter app and an offline mock server that follow this flow end to end. Start from its README.

Security & Compliance

Some of this is enforced for you by the API; the rest is yours to build. The marker on each rule says which — assume nothing is handled on your behalf unless it is labelled RunnerGeeks enforces.

  • RunnerGeeks enforces
  • Yours to implement
  • Shared

The client secret must never reach the mobile app. The app holds a per-user token and nothing else; anything that needs the partner token — events, user sync, webhooks — is called from your backend.

Credentials & tokens

Two credentials, two homes: the client secret never leaves your server, the user token never leaves the device.

client_secret is server-only Yours to implement
It is returned exactly once — at client creation and again on rotation — and stored only as a bcrypt hash, so it can never be read back. Keep it in your server secret store, out of the mobile binary, the repo, CI output and crash reports. The same applies to the webhook secret. POST /api/organizer/partner-clients
Partner token is server-to-server Yours to implement
rgp_ tokens live 30 minutes (expires_in: 1800). Mint one, cache it, reuse it until it is close to expiry — do not request a token per call. It must never be shipped to the mobile app or sent to a user-level endpoint. POST /oauth/token
End-user tokens on the device Yours to implement
The id_token lasts about an hour and the refresh token is rotated on every refresh: persist the new pair in platform secure storage (Keychain / Keystore) and discard the old one, or the next refresh fails. Neither belongs in logs, analytics events or crash reports. POST /auth/refresh
Rotation revokes immediately RunnerGeeks enforces
Rotating a secret re-hashes it and revokes every access token already issued under the old one in the same request. Rotate on a schedule, and immediately whenever a secret may have leaked. POST /api/organizer/partner-clients/{id}/rotate-secret
Suspend / revoke kills live tokens RunnerGeeks enforces
Moving a client to suspended or revoked — by you or by RunnerGeeks staff — revokes its outstanding tokens there and then, not merely at the next issuance. Every subsequent call answers 403 until the client is active again.
Auth endpoints are capped RunnerGeeks enforces
The token endpoint carries a fleet-wide 60/min cap because each attempt costs a bcrypt verify; the exchange and refresh endpoints are capped at 600/min. These are abuse brakes shared by all callers, so treat a 429 there as a signal that you are minting too often. /oauth/token/auth/exchange/auth/refresh

Access boundaries

What a token can reach is decided server-side — scope, partner linkage and tenant, in that order.

Least privilege on scopes Yours to implement
Self-serve grants are limited to exactly three scopes: events:read, users:sync and webhooks:manage. Anything beyond that is admin-granted after review. Ask only for what the client actually calls — a missing scope is a hard 403, never a partial result. events:readusers:syncwebhooks:manage
The two surfaces do not overlap RunnerGeeks enforces
User-level routes require an end-user who was synced through a partner client; an ordinary RunnerGeeks account token is rejected with 403. Partner-token routes, conversely, never accept a user token. Sending the wrong one is an error, not a fallback. POST /users/sync
Tenant isolation hides existence RunnerGeeks enforces
Every event read is scoped to the organizer behind your client. Another organizer's event answers 404, not 403 — a response never confirms that a resource exists, so absence of data is not a signal. Treat 404 as "not available" and do not probe for ids.
Transport and URL hygiene Shared
TLS everywhere, and the webhook URL must be https. Never put a token, a client secret or a custom token in a query string or path: URLs survive in proxy logs, referrer headers and crash dumps. Credentials belong in the Authorization header or the request body.

Personal & biometric data (UU PDP)

Face data is biometric personal data. Consent is yours to collect; the narrow search surface is ours to guarantee.

Consent before enrolment Yours to implement
Capture explicit, recorded consent in your own UI before you call the Face ID registration endpoint. RunnerGeeks cannot obtain that consent on your behalf and does not see your consent screen — the obligation sits with the app the user is looking at. POST /faces
Search matches the user's own face only RunnerGeeks enforces
Face search runs against the caller's own enrolled Face ID embeddings. There is deliberately no selfie-upload search endpoint on the partner surface, so nobody can upload an arbitrary photo to find a stranger. That absence is a safety property — do not route around it. POST /search/face-id
What is actually stored RunnerGeeks enforces
Enrolment stores one 512-dimension embedding per reference image plus face metadata. Original race photos are never stored by RunnerGeeks — the platform is a search layer over the provider's own URLs.
Deletion and erasure requests Shared
Deleting a Face ID removes the enrolled profile and its embedding, and your app should expose that. A broader erasure request covers more than face data: relay it to RunnerGeeks, where it is handled under the privacy policy — do not answer it on our behalf. DELETE /faces/{id}
Data minimisation on sync Yours to implement
User sync carries three fields: external_id, email and name. Send nothing else. The optional email_verified flag is accepted but ignored unless RunnerGeeks has marked your client as trusted after vetting, so never rely on it to skip your own verification. external_idemailname

Operational hardening

Rate budgets and webhook delivery — where a correct integration and a fragile one diverge.

Per-client rate budget Shared
Each client has its own per-minute budget: 60/min by default, raisable to 600/min by the owning organizer and higher only by arrangement. Over budget answers 429 — back off exponentially and queue, because retrying hot only burns the next window. A budget change takes effect within about 30 seconds. 429
Verify the signature before parsing Yours to implement
X-RG-Signature is t=<unix>,v1=<hex>, an HMAC-SHA256 over the string "<t>.<raw_body>" keyed by your webhook secret. It is computed over the raw bytes, so verify before JSON parsing and compare digests in constant time. X-RG-Signature
Reject replays yourself Yours to implement
The signature is timestamp-bound, but only your server can judge freshness: drop any delivery whose t is more than five minutes away from your clock, even when the HMAC matches. Without that check a captured request stays valid forever.
Delivery is at-least-once Shared
Store X-RG-Delivery-Id and ignore an id you have already processed. A non-2xx response or a 10-second timeout is retried at 30s, 2m, 10m, 1h, 6h and 24h; after six attempts the delivery moves to the dead-letter queue, visible in the webhook status endpoint. X-RG-Delivery-IdGET /webhooks
Webhook secret is shown once Yours to implement
Registering an endpoint returns the signing secret a single time and immediately sends a handshake.ping so you can confirm reachability. Store the secret alongside your other server secrets; re-registering issues a new one and invalidates the old. POST /webhookshandshake.ping
Webhook URLs are SSRF-screened RunnerGeeks enforces
Registration rejects anything that is not https, and any host that is loopback, private, link-local, unspecified, or a .local / .internal / localhost name — which also blocks the cloud metadata address. Use a publicly resolvable hostname.
Payment credentials stay with us RunnerGeeks enforces
RunnerGeeks collects every payment, so your app never sees, holds or forwards payment credentials — it renders the checkout instruction the API returns and nothing else. On a 5xx during an order, treat the outcome as unknown and re-check the payment status endpoint rather than reporting a failed transaction. GET /orders/{id}/payment-status
Purchases are final — never offer a refund Yours to implement
Photos are digital products delivered as soon as payment is confirmed, so purchases are final and non-refundable under the RunnerGeeks Terms. There is no refund endpoint and no API call that triggers one, so your UI must not request, offer or promise a refund. A purchase that failed technically or was never authorised is a support matter: the buyer emails billing@runnergeeks.id with the order number and RunnerGeeks processes it by hand. Handle the refunded status and refunded_at when they do appear — just never present them as an action. refundedrefunded_atbilling@runnergeeks.id