Skip to main content

Developer reference

Custom list API

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:

  1. Go to Atlas Pickup Points → Settings.
  2. In the API keys section, click Create key.
  3. Enter a name that identifies the system that will use the key, for example “Nightly CRM sync”.
  4. Under Access scopes, select the scopes your integration needs. write_custom_lists includes read_custom_lists.
  5. 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_code is set) fails with CUSTOM_LIST_UNEXPECTED_SHRINK. The message states how many locations it would delete. If the deletion is intended, repeat the request with confirm_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:

  • type and enabled are Custom list fields. A Custom endpoint does not have them.
  • business_hours times must be in 24-hour HH:MM format with a two-digit hour (08:00, not 8:00 or 8am).
  • GET responses include every field, with null, 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"
    }
  ]
}
  • error is a stable code. Match on it, not on message, which may change.
  • formErrors is present on validation errors. Each path points to the invalid field in your request body, with array indexes: locations.3.address.zip is the postal code of the fourth location.
  • requestId identifies 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.