The Custom list API lets your own systems manage the Custom list of pickup points: a CRM, a store locator, an ERP, or a nightly export job. Instead of editing locations one by one in Shopify Admin, your server sends the full list and Atlas works out what to create, update, and delete.
The API is keyed on your own code for each location, so you never handle Atlas identifiers. It accepts the same JSON shape a Custom endpoint returns, so a file you already host for a Custom endpoint uploads as it is.
#Endpoints
All endpoints are served under https://app.atlaspickuppoints.com/api/custom_list/v1.
| Method | Path | Scope | Description |
|---|---|---|---|
PUT |
/locations |
write_custom_lists |
Replace the list with the one you send. |
GET |
/locations |
read_custom_lists |
Read the list back in the same shape. |
GET |
/locations/summary |
read_custom_lists |
Get counts per country and the remaining room. |
#Authentication
Every request needs an API key of the store whose list you manage. To create one:
- Go to Atlas Pickup Points → Settings.
- In the API keys section, click Create key.
- Enter a name that identifies the system that will use the key, for example “Nightly CRM sync”.
- Under Access scopes, select the scopes your integration needs.
write_custom_listsincludesread_custom_lists. - Click Create key, then copy the key. It starts with
atlas_sk_and is shown only once.
Send the key in the Authorization header of every request:
Authorization: Bearer atlas_sk_...
The key identifies the store, so there is no shop parameter in any request. A store can have up to 2 active keys. To rotate a key, revoke the one you are replacing, then create a new one. Scopes are fixed when the key is created.
#Replace the list
PUT /locations replaces your list with the locations in the request body. Atlas matches locations on code:
- A code that is not in your list yet creates a location.
- A code that is already in your list updates that location. Every field is overwritten, including
enabled. - A code in your list that is missing from the request deletes that location.
The request is idempotent: sending the same body again changes nothing. Atlas validates the whole body before writing anything, so a replace either applies completely or not at all, and a validation error lists every problem at once.
| Query parameter | Type | Description |
|---|---|---|
country_code |
String | Optional. Replace only this country’s locations (ISO 3166-1 alpha-2) and leave other countries untouched. Omit it to replace the whole list. |
confirm_shrink |
Boolean | Optional, defaults to false. Set to true to accept a replace that deletes more than half of the list. See Safeguards. |
curl -X PUT "https://app.atlaspickuppoints.com/api/custom_list/v1/locations?country_code=GB" \
-H "Authorization: Bearer atlas_sk_..." \
-H "Content-Type: application/json" \
-d @locations.json
The body is an object with a locations array of Location objects:
{
"locations": [
{
"code": "LON-01",
"type": "PUDO",
"address": {
"address1": "214 Oxford Street",
"city": "London",
"zip": "W1D 1LA",
"country_code": "GB",
"latitude": 51.5153,
"longitude": -0.1407
},
"details": {
"name": "Showroom London Oxford Street",
"description": "Second floor, next to the lifts",
"business_hours": [
{ "day": "MONDAY", "opening_time": "10:00", "closing_time": "20:00" },
{ "day": "TUESDAY", "opening_time": "10:00", "closing_time": "20:00" },
{ "day": "WEDNESDAY", "opening_time": "10:00", "closing_time": "20:00" },
{ "day": "THURSDAY", "opening_time": "10:00", "closing_time": "20:00" },
{ "day": "FRIDAY", "opening_time": "10:00", "closing_time": "20:00" },
{ "day": "SATURDAY", "opening_time": "10:00", "closing_time": "16:00" }
]
},
"attributes": [{ "key": "region", "value": "Greater London" }]
},
{
"code": "MAN-LOCKER-01",
"type": "APM",
"address": {
"address1": "50 Market Street",
"city": "Manchester",
"zip": "M1 1PW",
"country_code": "GB",
"latitude": 53.4823,
"longitude": -2.2413
},
"details": {
"name": "Locker Manchester Arndale",
"open_24_hours": true
},
"enabled": false
}
]
}
To clear the list (or one country’s locations), send an empty array: { "locations": [] }. A body without the locations property is rejected.
The response reports what the replace changed:
{
"created": 1,
"updated": 1,
"deleted": 0,
"unchanged": 38,
"total_count": 40
}
| Key | Type | Description |
|---|---|---|
created |
Integer | Locations added. |
updated |
Integer | Existing locations whose fields changed. |
deleted |
Integer | Locations removed because their code was missing from the request. |
unchanged |
Integer | Existing locations sent with identical fields. |
total_count |
Integer | Locations in the whole list after the replace, in all countries. |
#Replacing one country
Pass country_code when your system owns only one country’s locations, or when you sync each country from a different source. Only that country’s locations are created, updated, or deleted.
Every location in the request must be in that country. A code that already belongs to a location in another country is rejected, because a replace scoped to one country cannot move a location between countries. To move one, delete it from the old country first, or replace the whole list without country_code.
#Safeguards
Two checks refuse a replace as a whole with 409 Conflict, so a failed export cannot empty your list or overflow it:
- More than half of the list would be deleted. A replace that would delete more than half of the existing locations (in the country, when
country_codeis set) fails withCUSTOM_LIST_UNEXPECTED_SHRINK. The message states how many locations it would delete. If the deletion is intended, repeat the request withconfirm_shrink=true. - The list would exceed the limit. A store can hold up to 5,000 locations, enabled or disabled. A replace that would go over the limit fails with
CUSTOM_LIST_CAP_EXCEEDED. For a scoped replace, locations in other countries count toward the limit too.
#Read the list
GET /locations returns your list in the same shape PUT accepts, so you can compare it with your own data before writing, or verify the result afterwards. Disabled locations are included with enabled: false. Locations are sorted by code.
| Query parameter | Type | Description |
|---|---|---|
country_code |
String | Optional. Return only this country’s locations. |
limit |
Integer | Optional. Locations per page, from 1 to 1,000. Defaults to 250. |
offset |
Integer | Optional. Number of locations to skip. Defaults to 0. |
curl "https://app.atlaspickuppoints.com/api/custom_list/v1/locations?country_code=GB&limit=250" \
-H "Authorization: Bearer atlas_sk_..."
{
"locations": [
{
"code": "MAN-LOCKER-01",
"type": "APM",
"address": {
"address1": "50 Market Street",
"address2": null,
"city": "Manchester",
"zip": "M1 1PW",
"province": null,
"province_code": null,
"country_code": "GB",
"latitude": 53.4823,
"longitude": -2.2413
},
"details": {
"name": "Locker Manchester Arndale",
"description": null,
"business_hours": [],
"open_24_hours": true
},
"attributes": [],
"icon_url": null,
"enabled": false
}
],
"total_count": 40,
"next_offset": 250
}
total_count is the number of locations that match the query across all pages. To read the whole list, repeat the request with offset set to next_offset until next_offset is absent or null.
#Get a summary
GET /locations/summary returns counts for the whole list. Use it to check that a sync landed or how much room is left before the limit.
curl "https://app.atlaspickuppoints.com/api/custom_list/v1/locations/summary" \
-H "Authorization: Bearer atlas_sk_..."
{
"total_count": 52,
"enabled_count": 49,
"cap": 5000,
"remaining": 4948,
"by_country": [
{ "country_code": "GB", "count": 40, "enabled_count": 37 },
{ "country_code": "IE", "count": 12, "enabled_count": 12 }
],
"last_location_update_at": "2026-09-29T02:00:14Z"
}
| Key | Type | Description |
|---|---|---|
total_count |
Integer | All locations, enabled or disabled. This is what counts toward the limit. |
enabled_count |
Integer | Locations buyers can see at checkout. |
cap |
Integer | Maximum number of locations the store can hold. |
remaining |
Integer | Locations you can still add before reaching cap. |
by_country |
Array | country_code, count, and enabled_count for each country in the list. |
last_location_update_at |
String | ISO 8601 time of the most recent update among the current locations, or null when the list is empty. Deleting a location can move it backwards, so confirm a sync from the counts the replace returns instead. |
#Location object
The same object is used in PUT requests and GET responses. It matches the Custom endpoint response, with a few differences:
typeandenabledare Custom list fields. A Custom endpoint does not have them.business_hourstimes must be in 24-hourHH:MMformat with a two-digit hour (08:00, not8:00or8am).GETresponses include every field, withnull,false, an empty string, or an empty array for values you did not set.
| Key | Required | Type | Description |
|---|---|---|---|
code |
Yes | String | Your identifier for the location, unique within your list. Replaces match on it, and Atlas stores it on the order, so use the identifier your fulfillment system knows. |
type |
No | PUDO or APM |
PUDO for a staffed counter or shop, or APM for a locker. Defaults to PUDO. Used by the location type filter in the configuration. |
address |
Yes | Address | Location address. |
details |
Yes | Details | Name, description, and opening hours. |
attributes |
No | Attribute[] | Key/value pairs stored with the buyer’s selection and passed to the order. Their keys can also be used in the configuration’s filters. |
icon_url |
No | String | URL of a custom map pin icon for this location on the checkout map. Should be a PNG hosted on the Shopify CDN. |
enabled |
No | Boolean | Whether buyers can see the location at checkout. Defaults to true. Set to false to hide it without deleting it. |
#Address object
| Key | Required | Type | Description |
|---|---|---|---|
latitude |
Yes | Number | Latitude in decimal degrees, from -90 to 90. |
longitude |
Yes | Number | Longitude in decimal degrees, from -180 to 180. The pair 0, 0 is rejected. |
address1 |
Yes | String | Street and house number, shown to the buyer together with the city. |
address2 |
No | String | Second address line. |
city |
Yes | String | City, shown to the buyer. |
zip |
Yes (except RO) | String | Postal code. Required in every country except Romania. |
province |
No | String | Province or state name. |
province_code |
No | String | Province or state code. |
country_code |
Yes | String | ISO 3166-1 alpha-2 country code, uppercase. Determines which configurations serve the location. |
#Details object
| Key | Required | Type | Description |
|---|---|---|---|
name |
Yes | String | Shown to the buyer as the pickup point name. |
description |
No | String | Shown under the name at checkout, for hints such as “second floor” or “next to the pharmacy”. |
business_hours |
No | BusinessHour[] | Opening hours. A day without a period is shown as closed. Add two periods to a day to model a lunch break. |
open_24_hours |
No | Boolean | Set to true for a location that is always open, such as a locker. Buyers see this instead of a weekly schedule. |
#BusinessHour object
| Key | Required | Type | Description |
|---|---|---|---|
day |
Yes | MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY SUNDAY |
Day of the week. |
opening_time |
Yes | String | Opening time in 24-hour HH:MM format, local to the location. |
closing_time |
Yes | String | Closing time in 24-hour HH:MM format, local to the location. |
#Attribute object
| Key | Required | Type | Description |
|---|---|---|---|
key |
Yes | String | Attribute name, up to 255 characters. |
value |
Yes | String, Number, or Boolean | Attribute value. |
#Errors
Every error response has the same shape:
{
"error": "BAD_REQUEST",
"message": "Validation error",
"requestId": "3f1c2a9e-7b4d-4e1a-9c6f-2d8b5e0a7c41",
"formErrors": [
{
"path": "locations.3.address.zip",
"message": "Postal code is required for GB"
}
]
}
erroris a stable code. Match on it, not onmessage, which may change.formErrorsis present on validation errors. Eachpathpoints to the invalid field in your request body, with array indexes:locations.3.address.zipis the postal code of the fourth location.requestIdidentifies the request. Include it when you contact us about a failed request.
| Status | Error | Description |
|---|---|---|
| 400 | BAD_REQUEST |
The request or one of its locations is invalid. See formErrors for each problem. |
| 401 | UNAUTHORIZED |
The API key is missing, invalid, or revoked. |
| 403 | FORBIDDEN |
The API key does not have the scope the endpoint requires. |
| 409 | CUSTOM_LIST_UNEXPECTED_SHRINK |
The replace would delete more than half of the list. See Safeguards. |
| 409 | CUSTOM_LIST_CAP_EXCEEDED |
The replace would exceed the 5,000 location limit. |
| 429 | RATE_LIMITED |
Too many requests with this key. Wait the number of seconds in the Retry-After header, then try again. |
#Rate limits
Requests are limited per API key. Over the limit, the API responds with 429 RATE_LIMITED and a Retry-After header.
#Order data
A location synced through the API behaves exactly like one added in Shopify Admin. When a buyer selects it, the provider field of the order metafield is CUSTOM_LIST and the code field contains the location’s code. See the integration guide for the metafield structure.