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.
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
- POST /oauth/token
Exchange client_id + client_secret for a partner token
none — no token required - POST /auth/exchange
Exchange a sync custom_token for an end-user id_token (no Firebase SDK needed)
none — no token required - POST /auth/refresh
Refresh an expiring id_token; the refresh token is rotated
none — no token required
User sync 1
- POST /users/sync
Register/link an end-user, get a Firebase custom token
partner — partner token — server-to-server
Events 1
- GET /events
List events owned by this organizer
partner — partner token — server-to-server
Webhooks 3
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 - GET /faces/{id}
Read one Face ID
user — end-user token — Firebase ID token - PUT /faces/{id}
Update a Face ID (rename / set primary)
user — end-user token — Firebase ID token - DELETE /faces/{id}
Delete a Face ID
user — end-user token — Firebase ID token
Photos & search 4
- GET /photos
Event photos — filter by bib or search_id (event_id required)
user — end-user token — Firebase ID token - POST /search/face-id
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 - POST /cart/items
Add a photo to the cart
user — end-user token — Firebase ID token - POST /cart/items/batch
Add up to 200 photos at once — partially succeeds, reports added[] / skipped[]
user — end-user token — Firebase ID token - DELETE /cart/items/{id}
Remove one cart item
user — end-user token — Firebase ID token - DELETE /cart
Empty the cart
user — end-user token — Firebase ID token - POST /cart/checkout
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 - GET /orders
Order history
user — end-user token — Firebase ID token - GET /orders/{id}
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 - POST /orders/{id}/pay
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 - PATCH /orders/{id}/cancel
Cancel an order
user — end-user token — Firebase ID token
Favorites 3
- POST /favorites
Toggle a favorite
user — end-user token — Firebase ID token - GET /favorites
List favorites
user — end-user token — Firebase ID token -
Favorite counts per type
user — end-user token — Firebase ID token
Purchased photos 1
- GET /purchases
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:
| Credential | Held by | Used 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.
-
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.
-
Open the Developers dashboard
In the left sidebar go to Studio → API & Webhooks. The page opens on the Credentials tab by default.
-
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.
Scope Type What it's for events:read scope List events owned by the organizer users:sync scope Register/link an end-user webhooks:manage scope Manage the webhook endpoint - Name — a
label you'll recognise (e.g.
-
Save your credentials — now, once only
After clicking Create, the dialog shows your
client_idandclient_secret.Credential dialogCopy safe to shareClient ID
rgpc_xxxxxxxxxxxxxxxx
Shown once onlyClient Secret
rgps_••••••••••••••••••••••••••••••••
The
client_secretcannot 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. -
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_idorclient_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:
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:
{
"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 fields | Type | What it's for |
|---|---|---|
| access_token | string | Bearer token for all partner endpoints (prefix rgp_) |
| token_type | string | Always Bearer |
| expires_in | int | Seconds until expiry (1800 = 30 min) |
| scope | string | Space-separated scopes granted to this client |
- The token is valid for 30 minutes (
expires_inseconds). Cache and reuse it until close to expiry — never fetch a fresh token per call. - Any
grant_typeother thanclient_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:
Step by step
-
User → your app
Signs in with your own auth
-
Your app → your server
Hands over the signed-in identity
external_id, email, name -
Your server → RunnerGeeks
Links or creates the RunnerGeeks account
POST /users/sync (partner token) -
RunnerGeeks → your server
Returns the linked user and a one-shot custom token
{ user_id, custom_token } -
Your server → your app
Passes only the custom token — never the partner token
-
Your app → RunnerGeeks
Trades it for an end-user session
POST /auth/exchange -
RunnerGeeks → your app
Returns the session the app will carry
{ id_token, refresh_token, expires_in } -
Your app → RunnerGeeks
Calls every user-level endpoint with that id_token
Authorization: Bearer <id_token> -
Your app → RunnerGeeks
Refreshes before the hour is up; store the rotated token
POST /auth/refresh
Step 1 — your server calls sync (partner token):
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 body | Type | What it's for |
|---|---|---|
| external_id | string | User ID in the partner system (required, unique) |
| string | User email — used for auto-bind (required, valid) | |
| name | string | Full name shown on RunnerGeeks (required) |
| email_verified | bool | Optional; ignored unless the client has the trusted_email flag |
Response — 200 OK:
{
"code": 0,
"success": true,
"data": {
"user_id": "300df6f5-9385-4b77-aed5-8b3fe54230ef",
"firebase_uid": "LzfWajvertheqQeLnNQXRtLbuAg2",
"created": true,
"custom_token": "eyJhbG…9In0…"
}
}| data fields | Type | What it's for |
|---|---|---|
| user_id | uuid | RunnerGeeks user UUID |
| firebase_uid | string | Firebase UID behind the account |
| created | bool | true if new account, false if already existing (idempotent) |
| custom_token | string | Firebase custom token — exchange it for an ID token |
- Idempotent:
calling again with the same
external_idreturns the sameuser_idwithcreated: 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:
POST /api/partner/v1/auth/exchange
Content-Type: application/json
{ "custom_token": "<custom_token from step 1>" }
{
"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):
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
| Scope | Type | What it's for |
|---|---|---|
| events:read | scope | List events owned by the organizer |
| users:sync | scope | Register/link an end-user |
| webhooks:manage | scope | Manage 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
/events partner tokenOnly events owned by the organizer behind the credential in use.
GET /api/partner/v1/events?page=1&per_page=20
Authorization: Bearer <partner_token>
Response — 200 OK:
{
"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[] fields | Type | What it's for |
|---|---|---|
| id | uuid | Event UUID |
| name | string | Event name |
| description | string? | Event description (nullable) |
| location | string? | Event location (nullable) |
| event_date | date? | Event date (nullable) |
| status | string | e.g. published |
| slug | string | URL slug |
| thumbnail_url | string? | Event cover image — falls back to banner_url when no dedicated thumbnail is set |
| banner_url | string? | Event banner image (nullable) |
| bib_search_support | bool | Whether BIB search is enabled for this event |
3.2 Webhooks
Register a URL to receive notifications (details in the Webhooks section):
POST /api/partner/v1/webhooks
Authorization: Bearer <partner_token>
Content-Type: application/json
{ "url": "https://partner.example.com/rg-webhook" }
Response — 200 OK:
{
"secret": "rgwh_…",
"handshake_delivery_id": "…"
}| data fields | Type | What it's for |
|---|---|---|
| secret | string | HMAC secret (rgwh_…) — shown ONCE, cannot be retrieved later |
| handshake_delivery_id | uuid | Delivery ID of the handshake.ping verification event |
Other webhook operations
/webhooks partner tokenCurrent 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.
{
"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
/webhooks partner tokenUnregisters the endpoint and clears the signing secret. Registering again issues a new secret — rotate by calling POST /webhooks, which replaces both in one step.
{ "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.
/faces user tokenEnrol 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).
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 body | Type | What it's for |
|---|---|---|
| name | string | Label for the Face ID (1–255 chars, required) |
| images | string[] | 1–5 base64-encoded photos (JPEG, PNG or WebP, ≤ 5 MB each). Embedded, then discarded. One request is one change. |
| angles | string[]? | Optional: front | left | right | up | down per image, same order as images |
Response — 200 OK (one object per photo):
{
"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[] fields | Type | What it's for |
|---|---|---|
| id | uuid | Face profile (reference) id |
| name | string | Profile label (includes the angle when given) |
| photo_url | null | Always null for partner uploads. No image is stored. |
| is_primary | bool | false on creation; set the primary with PUT /faces/{id} |
| has_embedding | bool | true once the photo is embedded, which is already true in the response |
| Limits | Type | What it's for |
|---|---|---|
| 5 photos | per user | A Face ID holds at most 5 reference photos. Over the limit → 409. |
| 3 sets | per 7 days | Rolling 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. |
| angles | guidance | Use 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:
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} delete4.2 Photos
/photos user tokenOne 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):
| Param | Required | Notes |
|---|---|---|
| event_id | yes | UUID of the event to list photos from |
| bib | no | BIB number → BIB filter (needs bib_search_support) |
| search_id | no | handle from POST /search/face-id → face-results mode |
| distance | no | 0 = exact (default), 1 = tolerate one digit (OCR slip) — with bib |
| page, per_page | no | pagination (per_page max 100) |
a) All photos of an event
GET /api/partner/v1/photos?event_id={uuid}&page=1&per_page=20
Authorization: Bearer <enduser_token>
Response — 200 OK:
{
"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[] fields | Type | What it's for |
|---|---|---|
| id | uuid | Photo UUID |
| filename | string | Provider file name |
| source_url | string | The 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_provider | string | e.g. google_photos |
| width / height | int? | Original pixel size (nullable until probed) |
| filesize | long | Bytes of the original |
| capture_date | date? | EXIF capture time (nullable) |
| album_name / event_name | string | Human-readable album and event |
| is_for_sale | bool | Whether the photo is purchasable |
| signed_url | string? | Short-lived watermarked preview URL — present only when is_for_sale is true, and equal to source_url then |
| effective_price | decimal | price_override or the event base price |
| owner | object | Album owner: display_name, photo_url |
| total | int | Total photos in the event (for pagination) |
| page / per_page | int | Echo 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.
GET /api/partner/v1/photos?event_id={uuid}&bib=10045&distance=0&page=1
Authorization: Bearer <enduser_token>
Response — 200 OK:
{
"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[] fields | Type | What it's for |
|---|---|---|
| …photo fields | — | Same item shape as the photos endpoint (§ above) |
| bib_match.bib_number | string | BIB as indexed from the photo |
| bib_match.distance | int | Edit distance from the query (0 = exact) |
| total / page / per_page | int | EntriesResult 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.
GET /api/partner/v1/photos?event_id={uuid}&search_id={search_id}&page=1&per_page=20
Authorization: Bearer <enduser_token>
Response — 200 OK:
{
"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[] fields | Type | What it's for |
|---|---|---|
| …photo fields | — | Same item shape as the default photos listing (§ 4.2a): source_url, signed_url, effective_price, owner… |
| photo_id | uuid | Matched photo id (echo) |
| similarity_score | float | Match similarity 0–1 (higher = closer) |
| preview_url | string | Reserved — 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_support | int? | How many reference angles matched (null for single-angle searches) |
| total / page / per_page | int | Same 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).
/search/face-id user tokenJSON body with event_id (required); threshold/limit/album_id optional. No image upload.
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:
{
"search_id": "7c2a…",
"total_matches": 14
}| data fields | Type | What it's for |
|---|---|---|
| search_id | uuid | Handle for the started search |
| total_matches | int | Total 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.
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 -> orderThe 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
/cart user tokenCart 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.
GET /api/partner/v1/cart
Authorization: Bearer <enduser_token>
Response — 200 OK:
{
"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 fields | Type | What it's for |
|---|---|---|
| items | array | Cart rows, oldest first (ordered by created_at ascending) |
| groups | array | Bulk 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 |
| subtotal | decimal string | Sum of every items[].unit_price. Indicative — no coupon, tax or payment fee is applied here |
| currency | string | ISO code of the first cart photo (e.g. "IDR"); "IDR" when the cart is empty |
| item_count | int | Number of rows in items[] — handy for a cart badge |
| items[] fields | Type | What it's for |
|---|---|---|
| cart_item_id | uuid | The cart row id. This — not photo_id — is the {id} for DELETE /cart/items/{id} |
| photo_id | uuid | The photo this row buys |
| item_type | string | Always "photo" in v1 ("face" is reserved) |
| face_id | uuid | null | Reserved for face-level purchase context; v1 still prices per photo |
| notes | string | null | Free-text note sent when the item was added (max 500 chars) |
| unit_price | decimal string | Live 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_url | string | Signed 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_bulk | bool | True 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_key | string · omitted | Present only on an item priced inside a package; matches groups[].key. Absent means the item is priced on its own |
| groups[] fields | Type | What it's for |
|---|---|---|
| key | string | "{event_id}|{seller_user_id}" — the same pair the order pipeline groups on, so the package you show is the one checkout charges |
| event_id | uuid | Event the package belongs to |
| seller_user_id | uuid | Photographer who set the package price |
| event_name | string | null | Event name, null when empty |
| item_count | int | Number of photos priced together |
| cart_item_ids | uuid[] | 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_price | decimal string | What the package costs together, exactly as the seller configured it |
| individual_total | decimal string | What the same photos would cost one by one |
| savings | decimal string | individual_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
/cart/items user tokenAdds 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.
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 body | Type | What it's for |
|---|---|---|
| photo_id | uuid · required | Photo to add. Must exist, be for sale, and not belong to the buyer |
| face_id | uuid | null · optional | Reserved for face-level purchase; stored on the row, does not change the price in v1 |
| notes | string | null · optional | Per-item note, max 500 characters |
| from_bulk | bool · optional · default false | Set 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:
{
"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 fields | Type | What it's for |
|---|---|---|
| id | uuid | Cart row id — the same value the quote returns as cart_item_id |
| user_id | uuid | Owner of the cart (the end user the token belongs to) |
| photo_id | uuid | Photo that was added |
| item_type | string | Always "photo" in v1 |
| face_id | uuid | null | As sent in the request |
| notes | string | null | As sent in the request |
| created_at | timestamp | When the row was first created — unchanged on a repeat add |
| updated_at | timestamp | Last change to the row |
| from_bulk | bool | Whether 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 message | Status | When |
|---|---|---|
| Photo not found | 404 | photo_id does not exist |
| Photo {id} is not for sale | 400 | The photo is not flagged for sale by its owner |
| You cannot purchase your own photo | 400 | The 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.
/cart/items/batch user tokenAdds 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.
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 body | Type | What it's for |
|---|---|---|
| photo_ids | uuid[] · required · 1–200 | Photos to add. An empty array is a 400, more than 200 is a 400, and repeated ids are de-duplicated before anything is written |
| notes | string | null · optional | One note applied to every row this call creates — there is no per-photo note in the batch form |
| from_bulk | bool · optional · default true | Defaults 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:
{
"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 fields | Type | What it's for |
|---|---|---|
| added | array | One entry per photo now in the cart, including photos that were already there |
| skipped | array | One entry per photo that could not be added, each with its own reason |
| added_count | int | added.length — count after de-duplication, so it can be lower than the ids you sent |
| skipped_count | int | skipped.length |
| added[] fields | Type | What it's for |
|---|---|---|
| photo_id | uuid | Photo as you sent it |
| cart_item_id | uuid | Cart row id — the handle for DELETE /cart/items/{id}, and the same value the quote returns |
| skipped[] fields | Type | What it's for |
|---|---|---|
| photo_id | uuid | Photo that was not added |
| reason | string | Human-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 message | Status | When |
|---|---|---|
| photo_ids must not be empty | 400 | The array was empty — nothing is written and there is no partial result |
| photo_ids accepts at most 200 photos per call | 400 | More than 200 ids in one call. Split them; the whole call is rejected, not trimmed |
Remove one item
/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.
DELETE /api/partner/v1/cart/items/9f1c2b84-7d3e-4a51-9c20-5b7e8a1d4f06
Authorization: Bearer <enduser_token>
Response — 200 OK:
{ "removed": true }removed is always true on a 200: an id that
matched nothing returns 404 Cart item not found instead.
Empty the cart
/cart user tokenDeletes 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.
DELETE /api/partner/v1/cart
Authorization: Bearer <enduser_token>
Response — 200 OK:
{ "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.
/cart/checkout user tokenConverts 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.
POST /api/partner/v1/cart/checkout
Authorization: Bearer <enduser_token>
Content-Type: application/json
{
"coupon_code": "HEMAT10",
"notes": "Kirim ke WhatsApp 08xx"
}
| Request body | Type | What it's for |
|---|---|---|
| coupon_code | string | null · optional | Discount code. Trimmed and upper-cased server-side, validated against expiry, max uses and minimum subtotal |
| notes | string | null · optional | Order-level note, stored on the order and visible to the organizer |
Response — 200 OK. The order exists, prices are frozen, the cart is empty:
{
"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 fields | Type | What it's for |
|---|---|---|
| order | object | The created order — the same shape every /orders endpoint returns |
| cleared_items | int | How many cart rows were deleted after the order was created. Non-zero means the cart is now empty |
| order fields | Type | What it's for |
|---|---|---|
| id | uuid | Order id — use it for /orders/{id}/pay and payment-status |
| order_number | string | Human-readable reference, e.g. RG-708BC7EE2079 — show this to the buyer |
| subtotal | decimal string | Frozen sum of items[].price (bulk/tier pricing already applied) |
| discount | decimal string | Coupon discount; "0.00" when no coupon_code was sent |
| tax | decimal string | Always "0.00" at checkout — reserved, no tax is computed in v1 |
| fee | decimal string | Always "0.00" at checkout; the payment-channel fee is added later by POST /orders/{id}/pay |
| total | decimal string | Amount payable = subtotal − discount. Recalculated to subtotal − discount + fee once a payment method is chosen |
| currency | string | ISO code, e.g. "IDR" |
| status | string | "pending" on creation → completed / cancelled |
| payment_status | string | "pending" on creation → paid / failed / expired / cancelled |
| delivery_status | string | "pending" on creation — flips once the organizer delivers |
| items | array | One row per purchased photo, with its frozen price |
| payment | object | null | Always null here — no payment exists until POST /orders/{id}/pay |
| notes | string | null | The note sent in the request |
| buyer | object | null | Always null here — populated on GET /orders/{id} |
| created_at | timestamp | When the order was created |
| updated_at | timestamp | Last change to the order |
| order.items[] fields | Type | What it's for |
|---|---|---|
| id | uuid | Order item id (used by delivery endpoints) |
| photo_id | uuid | Purchased photo |
| album_id | uuid | null | Album the photo belongs to |
| event_id | uuid | null | Event the photo belongs to |
| event_name | string | null | Event name, null when empty |
| album_name | string | null | Album name, null when empty |
| owner | object | null | Seller: id, display_name, photo_url, username |
| price | decimal string | Frozen price for this photo — never recomputed after checkout |
| currency | string | Currency of this item |
| thumbnail_url | string | null | Signed 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_url | string | null | Full-resolution original — null until the item is approved, and null again if the purchase was reversed |
| delivered_at | timestamp | null | When the organizer delivered it |
| approved_at | timestamp | null | When the purchase was approved |
| refunded_at | timestamp | null | Null on a normal purchase — purchases are final, and this is set only when RunnerGeeks reverses one by hand after a support request |
| delivery_url | string | null | Delivery 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_bulkflag 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.
totalat this point issubtotal − discount. The payment-channel fee is added tofeeandtotalonly 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 message | Status | When |
|---|---|---|
| Cart is empty | 400 | Nothing to check out — add an item first |
| {CODE} isn't a valid code. | 400 | Unknown coupon_code |
| {CODE} has expired. | 400 | The coupon is past its expiry |
| Spend {amount} to use {CODE}. | 400 | The cart subtotal is below the coupon's minimum |
| {CODE} has been fully redeemed. | 409 | The coupon hit its max_uses limit |
| One or more photos not found | 400 | A 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.
GET /api/partner/v1/photos/{id}/grab-all metadata only (cheap)
GET /api/partner/v1/photos/{id}/grab-all/photos the matched setThe 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
/photos/{id}/grab-all user tokenMetadata 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.
GET /api/partner/v1/photos/9ad3f201-6c7b-4e55-91af-0d2e85b3c714/grab-all
Authorization: Bearer <enduser_token>
{id} is the seed photo. Response
— 200 OK:
{
"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 fields | Type | What it's for |
|---|---|---|
| sample_url | string | Signed, 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. |
| bulk | object | The 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 fields | Type | What it's for |
|---|---|---|
| event_id | uuid | Event the config belongs to. |
| seller_user_id | uuid | Seller the config belongs to — the same id as owner.id. |
| mode | flat | tier | flat = one package price for the whole set; tier = percentage discounts that unlock at photo counts. |
| flat_price | decimal (string) | null | Package price when mode is flat. Null in tier mode. A JSON string — parse it as a decimal, never a float. |
| tiers | array | null | When 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
/photos/{id}/grab-all/photos user tokenThe 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.
GET /api/partner/v1/photos/9ad3f201-6c7b-4e55-91af-0d2e85b3c714/grab-all/photos?threshold=0.4
Authorization: Bearer <enduser_token>
| Query parameters | Type | What it's for |
|---|---|---|
| threshold | float · 0.3–1.0 · default 0.4 | Minimum 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:
{
"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 fields | Type | What it's for |
|---|---|---|
| photos | array | The matched set, in the order the search ranked it. Same match shape the face-search results use — render it, do not re-sort it. |
| total | int | Size of the returned set. There is no paging: the set is the response. |
| photos[] fields | Type | What it's for |
|---|---|---|
| photo_id | uuid | The id you collect and post to /cart/items/batch. This is the only field the grab flow strictly needs. |
| face_id | uuid | The face inside the photo that matched. |
| score / final_score | float | Raw cosine similarity and the ranked score. Useful for a “best match” badge; the cut-off is threshold, not something you re-apply. |
| is_for_sale | bool | Whether 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_url | string | null | Watermarked preview, signed and expiring. Present only while the photo is for sale. |
| preview_url | string | Reserved — currently always an empty string. Render the photo from signed_url (on sale) or source_url, not from this field. |
| effective_price / price | number | Per-photo list price before any bulk discount. The real total comes from GET /cart once the batch is in — never add these up yourself. |
| currency | string | null | ISO code, e.g. IDR. |
| owner | object | null | Seller of this photo. Matches owner from the metadata call — a grab-all set is always one seller, one event. |
| angle_support | int | null | How 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.
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 frozenCollect 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.
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.| status | Set when | Meaning |
|---|---|---|
| pending | on create | Order exists, no payment attempt yet. Payable and cancellable. |
| awaiting_payment | on POST /pay | A checkout instruction has been issued. Still payable (the buyer may switch method) and still cancellable. |
| paid | payment settled | Terminal for the purchase. Revenue split written; delivery takes over from here. |
| cancelled | cancel / failed / expired | Terminal. 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. |
| refunded | exception, set by RunnerGeeks | Terminal. 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_status | Kind | Meaning |
|---|---|---|
| pending | non-terminal | Database default — no payment attempt has been made yet. Polling payment-status now returns 400 (no payment row). |
| waiting_payment | non-terminal | Checkout issued, waiting for the buyer. This is also the fallback for any payment state that is not one of the four below. |
| processing | non-terminal | The payment was accepted but has not settled yet (e.g. a bank transfer under review). Keep polling — do not unlock photos. |
| paid | terminal | Settled. Order status flips to paid, splits are created, and every non-refunded item's source_url is available from this point on. |
| failed | terminal | The payment was rejected, or the buyer abandoned it; order status becomes cancelled. The buyer must create a new order. |
| expired | terminal | Payment window elapsed (expiry job). Order status becomes cancelled. |
| cancelled | terminal | The 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. |
| refunded | terminal, exceptional | A 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_status | Set when | Meaning |
|---|---|---|
| pending | default | Nothing delivered yet. |
| partial | after a delivery | Some items have delivered_at, others are still outstanding. |
| delivered | after a delivery | Every 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
/orders user tokenBuy 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.
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 body | Type | What it's for |
|---|---|---|
| items[] | array · required | At least one entry — an empty array is a 400. |
| items[].photo_id | uuid · required | Must exist, must be is_for_sale, and must not be owned by the buyer (self-purchase → 400). |
| items[].bulk | bool · optional | Whether 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_code | string · optional | Case-insensitive. Invalid/expired/below-minimum → 400; fully redeemed → 409. |
| notes | string · optional | Free text echoed back on the order. |
Response — 200 OK:
{
"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
/orders user tokenThe 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.
GET /api/partner/v1/orders?page=1&per_page=20&status=waiting&search=merbabu
Authorization: Bearer <enduser_token>
| Query params | Type | What it's for |
|---|---|---|
| page | integer | 1-based. Default 1; values below 1 are clamped to 1. |
| per_page | integer | Default 20, capped at 500. |
| status | string | Filter bucket over payment_status — paid, waiting, failed or cancelled. Any other value is ignored (no filter). |
| search | string | Case-insensitive substring over order_number, event name and album name. Matched in SQL, so total stays correct. |
| status | Kind | Matches |
|---|---|---|
| paid | bucket | payment_status in paid, completed |
| waiting | bucket | payment_status in waiting_payment, pending, awaiting_payment, processing |
| failed | bucket | payment_status = failed |
| cancelled | bucket | payment_status in cancelled, expired |
Response — 200 OK:
{
"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 fields | Type | What it's for |
|---|---|---|
| items[] | order[] | Same object as the detail endpoint, except buyer is always null here. |
| total | integer | Rows matching page + filters, not the page size. |
| page / per_page | integer | Echoed back. |
| counts | object | Unfiltered 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
/orders/{id} user tokenThe full order, including the buyer block and the latest payment attempt. Another user's order is 403, an unknown id is 404.
GET /api/partner/v1/orders/c2f1a9d4-5e6b-4c7a-8d90-1f2e3a4b5c6d
Authorization: Bearer <enduser_token>
Response — 200 OK:
{
"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 fields | Type | What it's for |
|---|---|---|
| subtotal | decimal string | Sum of the frozen item prices. Never recomputed after creation. |
| discount | decimal string | Coupon value resolved at creation. |
| tax | decimal string | Always 0.00 today — reserved; keep reading it, do not assume it stays zero. |
| fee | decimal string | Buyer-borne payment fee. 0.00 until POST /pay, then set from the chosen method (and re-set if the buyer switches method). |
| total | decimal string | subtotal − discount + fee. Equals the amount charged. |
| currency | string | ISO code, IDR in practice. All money fields are JSON strings — parse as decimal, never as float. |
| data fields | Type | What it's for |
|---|---|---|
| id | uuid | Order id used in every /orders/{id} route. |
| order_number | string | Human reference, RG- + 12 hex characters. It travels with the payment too, so quote it in any support request. |
| status / payment_status / delivery_status | string | See the lifecycle tables above. |
| payment | object | null | Latest 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. |
| buyer | object | null | id, display_name, email of the buyer. Populated on detail only; always null in the list response. |
| notes | string | null | Echoed from the create call. |
| created_at / updated_at | timestamp | RFC 3339, UTC. |
| items[] fields | Type | What it's for |
|---|---|---|
| id | uuid | Order item id (used by the delivery approve/reject flow). |
| photo_id | uuid | The purchased photo. |
| album_id / event_id | uuid | null | Where the photo came from; event_name and album_name are the resolved labels (null when the source was deleted). |
| owner | object | null | Seller profile — id, display_name, photo_url, username. |
| price | decimal string | Price 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_url | string | null | A 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_url | string | null | The 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_at | timestamp | null | Delivery 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_url | string | null | Seller-supplied delivery link for review, present once the item is delivered. |
List payment methods
/payments/methods user tokenThe 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.
GET /api/partner/v1/payments/methods
Authorization: Bearer <enduser_token>
Response — 200 OK:
{
"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
/orders/{id}/pay user tokenIssues a checkout instruction for the chosen method. Only pending / awaiting_payment orders are payable — anything else is 400 'Order no longer payable'.
POST /api/partner/v1/orders/c2f1a9d4-…/pay
Authorization: Bearer <enduser_token>
Content-Type: application/json
{
"payment_method": "<method_code>",
"phone": "081234567890"
}
| Request body | Type | What it's for |
|---|---|---|
| payment_method | string · required | Method 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_id | string · optional | Instrument token, for methods that tokenise the card or wallet before the charge. Opaque — pass through whatever the checkout step handed you. |
| phone | string · optional | Buyer phone — required in practice by several e-wallet methods, so send it whenever you have it. |
Response — 200 OK:
{
"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 fields | Type | What it's for |
|---|---|---|
| payment_id | uuid | The payment row this attempt wrote. |
| order_id | uuid | Order being paid. |
| amount | decimal string | What 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_method | string | Echo of the requested method code. |
| provider | string | Identifier 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.reference | string | Reference 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_at | integer | null | Unix seconds when this checkout expires. null = this method sets no expiry of its own. |
| checkout.invoice_id | string | null | Internal object id reused when the buyer switches method. Opaque, and null for methods that have no update step. |
| checkout.instruction | object | Tagged union — { "kind": …, "data": { … } }. Switch on kind and render exactly one instruction. |
| instruction.kind | instruction.data | What to render |
|---|---|---|
| snap | token, redirect_url | Hosted checkout. Open redirect_url in a browser or in-app web view, or hand token to the checkout SDK you were given at onboarding. |
| va | number, bank | Show number and bank to the buyer so they can transfer the exact amount, then wait for the webhook. |
| qr | payload?, 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. |
| charge | pay_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. |
| redirect | redirect_url | Send the buyer to redirect_url and bring them back to your order screen afterwards. |
| embedded | link | Open 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
/orders/{id}/payment-status user tokenRe-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.
GET /api/partner/v1/orders/c2f1a9d4-…/payment-status
Authorization: Bearer <enduser_token>
{
"data": {
"status": "paid",
"payment_status": "paid"
}
}| data fields | Type | What it's for |
|---|---|---|
| status | string | Order status after the check — pending, awaiting_payment, paid, cancelled or refunded. |
| payment_status | string | Payment 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
/orders/{id}/cancel user tokenAllowed 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.
PATCH /api/partner/v1/orders/c2f1a9d4-…/cancel
Authorization: Bearer <enduser_token>
{
"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.
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 typeNot 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
/favorites user tokenOne 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.
POST /api/partner/v1/favorites
Authorization: Bearer <enduser_token>
Content-Type: application/json
{ "item_type": "photo", "item_id": "9ad3f201-…" }
| Request body | Type | What it's for |
|---|---|---|
| item_type | string — required | photo or event. Anything else is rejected with 400 — there is no third kind of favorite. |
| item_id | uuid — required | The photo id or the event id, matching item_type. |
{
"code": 0,
"success": true,
"message": null,
"data": {
"favorited": true,
"item_type": "photo",
"item_id": "9ad3f201-…"
}
}| data fields | Type | What it's for |
|---|---|---|
| favorited | bool | State after the call: true = now favorited, false = the favorite was removed. |
| item_type | string | Echoed back from the body. |
| item_id | uuid | Echoed 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
/favorites user tokenOne type per call, newest first. The response envelope is { items, total, page, per_page }; the item shape differs between the two types.
GET /api/partner/v1/favorites?item_type=photo
Authorization: Bearer <enduser_token>
| Query parameters | Type | What it's for |
|---|---|---|
| item_type | photo | event — default event | Which 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.
{
"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 fields | Type | What it's for |
|---|---|---|
| items | array | Favorites of that type, newest first. Shape depends on item_type (below). |
| total | int | All favorites of this type for the user, not just this page. |
| page | int | Always 1 on the partner surface. |
| per_page | int | Always 20 on the partner surface. |
| items[] · item_type=photo | Type | What it's for |
|---|---|---|
| id | uuid | Id of the favorite row itself. |
| item_type | string | Always photo here. |
| item_id | uuid | The favorited photo. |
| created_at | datetime | When the user favorited it. Orders the list, newest first. |
| source_url | string | For 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_url | string | Signed, time-limited imager URL, valid until its expires timestamp. Present only when is_for_sale is true, so treat it as optional. |
| width | int | null | Pixel width of the photo, when known. |
| height | int | null | Pixel height of the photo, when known. |
| is_for_sale | bool | Whether the photo is currently purchasable. |
| price_override | decimal | null | Per-photo price, when the organizer set one. |
| effective_price | decimal | What the buyer would pay: price_override, else the event's base price, else 0. |
| currency | string | The event's currency, defaulting to IDR. |
| event_id | uuid | null | Event the photo belongs to. |
| event_name | string | null | Event name. |
| album_name | string | null | Album name. |
| items[] · item_type=event | Type | What it's for |
|---|---|---|
| id | uuid | Id of the favorite row itself. |
| item_type | string | Always event here. |
| item_id | uuid | The favorited event. |
| source_url | string | Always an empty string for events — the field exists for shape parity with photos, so ignore it and read the event through GET /events. |
| created_at | datetime | When the user favorited it. The list is ordered by this, newest first. |
Favorite counts
/favorites/counts user tokenTwo totals for the signed-in user — one per item type. Cheap enough to call for a tab badge instead of paging the lists.
{
"code": 0,
"success": true,
"message": null,
"data": { "events": 3, "photos": 12 }
}| data fields | Type | What it's for |
|---|---|---|
| events | int | Number of events this user has favorited. |
| photos | int | Number 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
/purchases user tokenPaginated 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.
GET /api/partner/v1/purchases?page=1&per_page=20&sort=recent
Authorization: Bearer <enduser_token>
| Query parameters | Type | What it's for |
|---|---|---|
| page | int — default 1 | 1-based page number. Values below 1 are raised to 1. |
| per_page | int — default 20 | Items per page, clamped to 1–100 on this endpoint. |
| search | string | Case-insensitive substring over the event name, the album name, or the photo_id written as text. |
| event | string | Exact event name or album name. The literal "All events" is treated as no filter. |
| sort | recent | event | price | recent (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_status | pending | awaiting_approval | delivered | refunded | Server-side filter on delivery state. An unrecognised value is ignored and returns the unfiltered page. |
Response
{
"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 fields | Type | What it's for |
|---|---|---|
| items | array | This page of purchased items, in the order the server returned them. |
| total | int | Matching items across all pages, counted with the same filters and the same tenant scope. |
| page | int | Page echoed back. |
| per_page | int | Page size echoed back. |
| items[] fields | Type | What it's for |
|---|---|---|
| order_id | uuid | Order the item was bought in. |
| item_id | uuid | Order-item id — the handle for approve/reject calls. |
| photo_id | uuid | Photo 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. |
| price | decimal (string) | Price actually paid for this item, not the current list price. |
| currency | string | ISO code, e.g. IDR. |
| delivery_status | string | Derived state — one of the four values below. |
| thumbnail_url | string | Signed 150×200 preview. Always present, and expires — read it fresh, never cache it. |
| delivered_at | datetime | null | When the organizer delivered the file. |
| approved_at | datetime | null | When the buyer approved the delivery. |
| rejected_at | datetime | null | When the buyer rejected a delivery. Exposed on its own — there is no "rejected" delivery_status, and a rejected item reads as pending again. |
| refunded_at | datetime | null | Set only when RunnerGeeks reversed the purchase by hand — null on a normal item. See the note below. |
| delivery_url | string | null | Delivered file, for review. Null until delivered, and null again if the purchase was reversed. |
| source_url | string | null | Original 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".
| URL | Present when | What it is |
|---|---|---|
| thumbnail_url | always | Signed, time-limited preview of the photo. Present in every state, including a reversed purchase. |
| delivery_url | delivered_at set, refunded_at null | The delivered file, exposed so the buyer can review it before approving. States awaiting_approval and delivered. |
| source_url | order paid, refunded_at null | The 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_urlis signed with anexpirestimestamp — 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_urlwhatever 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.
pending ──(seller delivers)──▶ awaiting_approval
│
├──(approve)──▶ delivered buyer accepted the file
│
└──(reject)───▶ pending seller delivers againBoth 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
/orders/{id}/items/{item_id}/approve user tokenThe 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.
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:
{
"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 fields | Type | What it's for |
|---|---|---|
| id / order_id / photo_id | uuid | The 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_name | uuid | string | null | Where the photo sits. Null when the event or album was removed. |
| owner_id / owner_display_name / owner_photo_url / owner_username | null | Always null on this response — the seller is not re-resolved here. Read the seller from the purchases list instead. |
| price / currency | decimal (string) · string | What was paid for this item. Approving or rejecting never changes it. |
| delivered_at | datetime | null | Set when the seller delivered. Cleared back to null by reject. |
| approved_at | datetime | null | Set by approve, cleared by reject. It records that the buyer accepted the delivery; it does not gate source_url, which payment already unlocked. |
| rejected_at | datetime | null | Set by reject, cleared by approve — the two are mutually exclusive, so the pair always reads unambiguously. |
| refunded_at | datetime | null | Untouched by either call. Neither approve nor reject moves money. |
| delivery_url | string | null | The delivered file. Cleared by reject, since that link is exactly what the buyer rejected. |
| source_url | string | null | The 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 message | Status | When |
|---|---|---|
| Not your order | 403 | The 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 found | 404 | item_id is not in that order, or the order was never paid. Unpaid orders have nothing to approve. |
Reject an item
/orders/{id}/items/{item_id}/reject user tokenThe 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.
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 body | Type | What it's for |
|---|---|---|
| reason | string | null · optional | Passed 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):
{
"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 headers | Type | What it's for |
|---|---|---|
| X-RG-Signature | header | t=<unix_ts>,v1=<hex> — HMAC-SHA256 over "<ts>.<raw_body>" |
| X-RG-Event-Type | header | e.g. payment_status_changed, handshake.ping |
| X-RG-Delivery-Id | header | Idempotency key — store it, ignore duplicate deliveries |
| X-RG-Timestamp | header | Delivery time (unix) |
Verify on your side:
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 —
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-Idand ignore already-processed deliveries. Reject signatures outside the 5-minute window (replay). - Available events:
payment_status_changed(payload: order id + new payment status) andhandshake.ping.
Error Handling
Every error uses a uniform envelope:
{
"code": 404,
"error": true,
"message": "Not found: Event not found",
"data": null
}| Envelope fields | Type | What it's for |
|---|---|---|
| code | int | HTTP status |
| error | bool | Always true on error |
| message | string | Human-readable reason |
| data | null | Always null on error |
| HTTP status | Type | What it's for |
|---|---|---|
| 400 | Bad request | Invalid body/query, empty cart, bib+search_id together — fix the request; show message |
| 401 | Unauthorized | Token missing/expired — partner: request a new token; user: refresh Firebase |
| 403 | Forbidden | Missing scope, suspended/revoked client, not a partner end-user, or no Face ID for /search/face-id — do not retry; fix prerequisite |
| 404 | Not found | Does not exist or not yours — treat as "not available" |
| 409 | Conflict | e.g. coupon exhausted — show the message |
| 422 | Validation | Field validation failed — fix the field |
| 429 | Rate limited | Exponential backoff |
| 5xx | Server error | Retry 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:
Step by step
-
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.
-
The app exchanges the custom token
POST /auth/exchangeReturns the end-user id_token used for every personal call; renew it with POST /auth/refresh (~hourly).
-
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.
-
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.
-
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.
-
The user fills the cart
POST /cart/items · POST /cart/items/batch · GET /cartThe 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.
-
Checkout freezes the order
POST /cart/checkoutReturns one order with an order_number; prices are frozen and the cart is cleared.
-
The user pays
GET /payments/methods · POST /orders/{id}/payRead 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.
-
Confirm the payment
webhook payment_status_changed · GET /orders/{id}/payment-statusThe 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.
-
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.
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