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
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.
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.
| Budget | Pro | Corporate and above | Counted per |
|---|---|---|---|
| Requests per minute | 300 | 300 | key |
| Writing requests per day | 50,000 | 50,000 | key |
| Geocodings per calendar month | 20,000 | 20,000 | shop |
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 }
}
}
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." }
]
}
}
| Code | Status | What it means |
|---|---|---|
unauthorized | 401 | No key, or a key we do not accept. Deliberately the same answer for all four ways that can happen. |
plan_required | 403 | The shop is below Pro. |
forbidden_scope | 403 | The key exists but does not carry the scope this operation needs. |
plan_limit_reached | 402 | The shop already holds as many locations as the plan allows. |
validation_failed | 400 | The body or the query is wrong. fields says where. |
validation_failed | 422 | A batch whose envelope is fine but whose items are not. Nothing was written; items carries the indexes. |
not_found | 404 | No such resource. A location belonging to another shop is reported exactly like one that never existed. |
conflict | 409 | The external_id is taken, an Idempotency-Key is still in flight, or a batch job is already running for this shop. |
rate_limited | 429 | Out of requests for the minute, or writes for the day. |
api_disabled, api_not_provisioned | 503 | The API is not serving this installation yet. Retry later. |
internal | 500 | Our fault. Retry; if it persists, tell us the time and the endpoint. |
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.
Field names are snake_case everywhere. Text fields are at most 255 characters unless noted.
| Field | Type | Notes |
|---|---|---|
id | integer | Ours. Read only. |
progus_id | string | An opaque id used in storefront links. Read only. |
external_id | string, 64 | Yours. 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. |
name | string | Required on create. May be changed, never cleared. |
active | boolean | Whether the location appears on the storefront. |
priority | integer | Sort weight, 0 to 1,000,000. Higher sorts earlier. |
lat, lon | number | Send both to set coordinates yourself. Omit them and we geocode the address. |
place_id | string | A Google place id, if you have one. |
address, full_address, city, province, zip_code, country | string | The parts the geocoder works from. |
phone, fax, email, website | string | Shown in the location's card. |
image_url | string | An absolute URL to a picture of the location. |
info | string, 65535 | Free text. |
facebook, google, linkedin, instagram, twitter, youtube | string | Social links. |
whatsapp | string | A phone number, which the storefront turns into a wa.me link, or a full URL. |
marker_id | integer | The marker style, when it is not the shop default. Read only here. |
opening_hours | object | See below. Send null to clear. |
custom_fields | object | Custom field id (as a string key) to value. Fields you do not mention keep what they had. Ids come from GET /custom-fields. |
tag_ids | array of integer | Replaces the location's tags. Every id must be one of the shop's own - GET /tags. |
created_at, updated_at | string | ISO 8601. Read only. updated_at is what updated_since filters on. |
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.
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.
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.
curl https://sl-app.proguscommerce.com/api/v1/locations/91234 \
-H "Authorization: Bearer $SL_API_KEY"
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" }
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}'
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"
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
}'
curl -X DELETE https://sl-app.proguscommerce.com/api/v1/locations/by-external-id/crm-8842 \
-H "Authorization: Bearer $SL_API_KEY"
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 } ] }
See rate limits.
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.
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:
422 listing the bad indexes and nothing is written - you fix the input and send it again, rather than reconciling 500 records against a list of failures.202 means the work is accepted, not done. A background worker applies the items.409 carrying the id of the job to wait for.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.
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:
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".DELETE the same path. It is a 204 even if it was never there, so a delete you send twice is not an error.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.
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.
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.