Store Locator API

A server-to-server HTTP API for the locations on your store locator map. Create them, update them, delete them, and keep them in step with whatever system already holds them.

Base URL https://sl-app.proguscommerce.com/api/v1 · OpenAPI: full, Salesforce External Services

Authentication

Every request carries an API key as a bearer token:

Authorization: Bearer sl_live_0123456789abcdef0123456789abcdef

Keys are created in the app, under API access. The full key is shown once, when it is created; we store only a hash of it, so it cannot be recovered afterwards. Lose it and you mint a new one and revoke the old.

Keys are not accepted in a query string - a key in a URL ends up in the access log of every proxy between you and us. Treat a key as a password: keep it on your server, never in a browser, a mobile app or a public repository.

A missing key, a malformed key, an unknown key and a revoked key all get the same 401. Revoking a key takes effect immediately.

Plan requirement

The API is available on the Pro plan and above. A shop below Pro gets:

HTTP/1.1 403 Forbidden
{"error":{"code":"plan_required","message":"The public API requires the Pro plan or higher.","plan":"business","requiredPlan":"pro"}}

The plan is re-checked at most a minute after it changes, so an upgrade starts working within a minute and a downgrade stops working within a minute.

Your plan also caps how many locations the shop may hold. A write that would exceed it is refused with 402 and plan_limit_reached, carrying the count and the limit.

Rate limits and headers

BudgetProCorporate and aboveCounted per
Requests per minute300300key
Writing requests per day50,00050,000key
Geocodings per calendar month20,00020,000shop

Throughput is the same on every plan that has the API. A batch of 500 locations is one writing request, so the daily budget is a guard against a runaway loop rather than a cap on how much data you may send. If your integration needs more than this, write to us and we will raise it for your key.

Every response carries the state of the per-minute window:

RateLimit-Limit: 300
RateLimit-Remaining: 297
RateLimit-Reset: 34

RateLimit-Reset is seconds until the window resets. A refusal is a 429 with Retry-After in seconds; the body says whether the window was the minute or the day.

Geocoding is counted per shop and per calendar month, not per key, so minting a second key does not buy a second geocoding budget. Addresses we have already resolved are reused, so a steady sync spends far less than its first load did. Running out is never an error: the location is still written, and the response says "geocoding":"skipped_quota" so you know its coordinates are not filled in yet. Send the location again next month, or set lat and lon yourself and no lookup is needed at all.

GET /limits reports all of this without spending any of it:

curl https://sl-app.proguscommerce.com/api/v1/limits \
  -H "Authorization: Bearer $SL_API_KEY"
{
  "plan": "pro",
  "locations": { "limit": 10000, "used": 348, "remaining": 9652 },
  "rateLimits": {
    "requestsPerMinute": { "limit": 300, "remaining": 299, "resetSeconds": 41 },
    "writesPerDay": { "limit": 50000, "remaining": 49994, "resetSeconds": 52193 }
  }
}

Errors

Every error has the same shape. Branch on code; message is a fixed English sentence for whoever reads your log.

{
  "error": {
    "code": "validation_failed",
    "message": "The request body is not valid.",
    "fields": [
      { "field": "name", "code": "required", "message": "This field is required." }
    ]
  }
}
CodeStatusWhat it means
unauthorized401No key, or a key we do not accept. Deliberately the same answer for all four ways that can happen.
plan_required403The shop is below Pro.
forbidden_scope403The key exists but does not carry the scope this operation needs.
plan_limit_reached402The shop already holds as many locations as the plan allows.
validation_failed400The body or the query is wrong. fields says where.
validation_failed422A batch whose envelope is fine but whose items are not. Nothing was written; items carries the indexes.
not_found404No such resource. A location belonging to another shop is reported exactly like one that never existed.
conflict409The external_id is taken, an Idempotency-Key is still in flight, or a batch job is already running for this shop.
rate_limited429Out of requests for the minute, or writes for the day.
api_disabled, api_not_provisioned503The API is not serving this installation yet. Retry later.
internal500Our fault. Retry; if it persists, tell us the time and the endpoint.

The Idempotency-Key header

POST /locations accepts an Idempotency-Key: up to 255 characters of your own choosing, one per logical create.

curl -X POST https://sl-app.proguscommerce.com/api/v1/locations \
  -H "Authorization: Bearer $SL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crm-account-8842-create" \
  -d '{"name":"Warsaw flagship","city":"Warsaw","country":"Poland"}'

If the connection drops before the 201 reaches you and you retry with the same key, you get the first response back rather than a second location. Keys are remembered for 24 hours. A retry that arrives while the first request is still running gets a 409 - ask again in a moment; it is not a permanent failure.

The other writes do not need it. PUT /locations/by-external-id/{external_id} is naturally idempotent - sending it twice leaves one location - and DELETE is a 204 whether or not there was anything to delete.

The location object

Field names are snake_case everywhere. Text fields are at most 255 characters unless noted.

FieldTypeNotes
idintegerOurs. Read only.
progus_idstringAn opaque id used in storefront links. Read only.
external_idstring, 64Yours. Unique within the shop. The key an upsert matches on. On PUT /locations/by-external-id/{external_id} it belongs in the URL; sending the same value in the body as well is accepted and ignored, a different value is refused.
namestringRequired on create. May be changed, never cleared.
activebooleanWhether the location appears on the storefront.
priorityintegerSort weight, 0 to 1,000,000. Higher sorts earlier.
lat, lonnumberSend both to set coordinates yourself. Omit them and we geocode the address.
place_idstringA Google place id, if you have one.
address, full_address, city, province, zip_code, countrystringThe parts the geocoder works from.
phone, fax, email, websitestringShown in the location's card.
image_urlstringAn absolute URL to a picture of the location.
infostring, 65535Free text.
facebook, google, linkedin, instagram, twitter, youtubestringSocial links.
whatsappstringA phone number, which the storefront turns into a wa.me link, or a full URL.
marker_idintegerThe marker style, when it is not the shop default. Read only here.
opening_hoursobjectSee below. Send null to clear.
custom_fieldsobjectCustom field id (as a string key) to value. Fields you do not mention keep what they had. Ids come from GET /custom-fields.
tag_idsarray of integerReplaces the location's tags. Every id must be one of the shop's own - GET /tags.
created_at, updated_atstringISO 8601. Read only. updated_at is what updated_since filters on.

Opening hours

A timezone is required, and each day appears at most once. day is 0 for Sunday through 6 for Saturday.

{
  "opening_hours": {
    "timezone": "Europe/Warsaw",
    "periods": [
      { "day": 1, "open": "09:00", "close": "18:00" },
      { "day": 2, "open": "09:00", "close": "18:00" },
      { "day": 0, "closed": true }
    ]
  }
}

Locations whose hours were typed into the app before structured hours existed come back with opening_hours as a plain string. Writing structured hours replaces that text.

Geocoding

Every write answers with a sibling geocoding field: pending (a lookup is queued and coordinates appear within minutes), not_needed (usable coordinates already, or no address to look up), or skipped_quota (out of budget for today - the location is written without coordinates).

An update only spends a geocoding when the address actually moved. Changing a phone number on a nightly full sync costs nothing. Setting lat to null is how you ask for a fresh lookup.

Endpoints

GET/locations

Lists locations, newest id last, with keyset pagination. Query: limit (1-200, default 50), cursor, updated_since (ISO 8601), active, external_id.

curl "https://sl-app.proguscommerce.com/api/v1/locations?limit=100&updated_since=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer $SL_API_KEY"
{ "data": [ { "id": 91234, "name": "Warsaw flagship", "...": "..." } ], "next_cursor": "OTEyMzQ=" }

Follow next_cursor as ?cursor= until it is null. The cursor is opaque - do not do arithmetic on it.

GET/locations/{id}
curl https://sl-app.proguscommerce.com/api/v1/locations/91234 \
  -H "Authorization: Bearer $SL_API_KEY"
POST/locations

Creates one location. name is required. Answers 201.

curl -X POST https://sl-app.proguscommerce.com/api/v1/locations \
  -H "Authorization: Bearer $SL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "crm-8842",
    "name": "Warsaw flagship",
    "address": "Marszalkowska 100",
    "city": "Warsaw",
    "country": "Poland",
    "phone": "+48 22 000 00 00",
    "active": true
  }'
{ "data": { "id": 91234, "external_id": "crm-8842", "...": "..." }, "geocoding": "pending" }
PATCH/locations/{id}

Changes only the fields you send.

curl -X PATCH https://sl-app.proguscommerce.com/api/v1/locations/91234 \
  -H "Authorization: Bearer $SL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+48 22 111 11 11","active":false}'
DELETE/locations/{id}

Answers 204, whether or not the location was there.

curl -X DELETE https://sl-app.proguscommerce.com/api/v1/locations/91234 \
  -H "Authorization: Bearer $SL_API_KEY"
PUT/locations/by-external-id/{external_id}

Creates or replaces the location carrying your own id. 201 when it was created, 200 when one was updated. See syncing from a CRM.

curl -X PUT https://sl-app.proguscommerce.com/api/v1/locations/by-external-id/crm-8842 \
  -H "Authorization: Bearer $SL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Warsaw flagship",
    "address": "Marszalkowska 100",
    "city": "Warsaw",
    "country": "Poland",
    "active": true
  }'
DELETE/locations/by-external-id/{external_id}
curl -X DELETE https://sl-app.proguscommerce.com/api/v1/locations/by-external-id/crm-8842 \
  -H "Authorization: Bearer $SL_API_KEY"
GET/custom-fields · GET/tags · GET/groups

The three dictionaries you need to map your own fields onto ours. Small, per shop, no pagination.

curl https://sl-app.proguscommerce.com/api/v1/tags \
  -H "Authorization: Bearer $SL_API_KEY"
{ "data": [ { "id": 12, "name": "Authorised dealer", "active": true, "active_on_form": false, "priority": 1 } ] }
GET/limits

See rate limits.

Batches and jobs

The first import of a dealer network is thousands of locations, and doing that one request at a time is slow for you and unkind to the rate limit. POST /locations/batch takes up to 500 items in one request. Every item is an upsert on its external_id, which is required.

POST/locations/batch
curl -X POST https://sl-app.proguscommerce.com/api/v1/locations/batch \
  -H "Authorization: Bearer $SL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "external_id": "crm-8842", "name": "Warsaw flagship", "city": "Warsaw", "country": "Poland" },
      { "external_id": "crm-8843", "name": "Krakow store",    "city": "Krakow", "country": "Poland" }
    ]
  }'
HTTP/1.1 202 Accepted
{ "job_id": 4471, "status": "queued", "accepted_count": 2 }

Three things are worth knowing:

GET/jobs/{id}
curl https://sl-app.proguscommerce.com/api/v1/jobs/4471 \
  -H "Authorization: Bearer $SL_API_KEY"
{
  "data": {
    "id": 4471,
    "type": "locations.batch",
    "status": "running",
    "total_count": 500,
    "processed_count": 200,
    "failed_count": 0,
    "errors": [],
    "created_at": "2026-09-08T09:12:44.000Z",
    "started_at": "2026-09-08T09:12:51.000Z",
    "finished_at": null
  }
}

queued and running are in progress. succeeded, partial and failed are terminal - stop polling. partial means some items applied and some did not; errors carries the index of each failure and why.

Poll every few seconds - a poll costs one request from the per-minute budget and no write budget. The suggested loop is: send a batch, poll until the status is terminal, send the next batch.

Syncing from a CRM

The problem with syncing two systems is usually bookkeeping: which of your records already exists on our side, and under which of our ids. PUT /locations/by-external-id/{external_id} removes that question. You address a location by your own identifier - a Salesforce record id, an ERP branch code, a row id from your database - and we work out whether that means create or update.

PUT /api/v1/locations/by-external-id/0015g00000XyZaBAAV

So a nightly sync is:

  1. For every account in your CRM that should appear on the map, PUT it under its record id. First run: they are all created (201). Every run after: they are all updated (200). No lookup, no stored mapping, no "have I sent this one before".
  2. For an account that was deleted or should no longer appear, DELETE the same path. It is a 204 even if it was never there, so a delete you send twice is not an error.
  3. Only sending changes? Use GET /locations?updated_since=... to see what our side looks like, or simply re-send everything - an update whose address has not moved costs no geocoding.

Bulk first import: use POST /locations/batch in chunks of 500, waiting for each job to reach a terminal status before sending the next. Steady-state nightly changes are usually few enough for individual PUTs.

A note on external ids

An external_id is unique within your shop. It must be stable - if your CRM's id for a branch changes, our side sees a new location and the old one stays behind. Use the immutable record id, not a name or a branch code somebody renames.

Salesforce External Services

If you are registering this API in Salesforce External Services, use the trimmed schema:

https://sl-app.proguscommerce.com/openapi/store-locator-salesforce.json

It contains only the two by-external-id operations, with flat schemas and JSON-only media types, which is what External Services accepts. Everything else in this document is in the full OpenAPI file. Note that External Services cannot send the nested fields - opening hours, custom fields, tags - so those are not in the trimmed schema; set them from the app, or use the full API from Apex.