Customer Self Service API
Overview
Every endpoint on this page acts on the customer making the request. There is no customer id anywhere in the URL — the account is resolved from the credential attached to the request and nothing else. That is the single difference that matters between this page and the admin endpoints in the Customer API, which name their target in the path (/api/customers/:customer_id/...) and require an admin token.
| Self service (this page) | Admin (customer.md) | |
|---|---|---|
| Path | /api/customers/me/... | /api/customers/:customer_id/... |
| route.json access | public | private |
| Credential | Customer JWT or storefront session cookie | Admin JWT or admin session cookie |
| Who is edited | Only the caller | Any customer named in the path |
| Protected fields | Stripped from the payload before the write | Writable |
access: "public" does not mean unauthenticatedThe public flag in route.json only tells the global admin auth middleware to stand down. Each handler then calls request.getCurrentCustomer() itself and answers 401 when there is no customer. A request with no credential is rejected — it is just rejected by the handler rather than by the router.
Authentication
Two credentials are accepted, checked in this order by the customer module's global middleware:
- Customer JWT —
Authorization: Bearer <token>, where the token was issued byPOST /api/customer/tokens(see the Authentication API). The middleware decodes the token first and ignores it unless itstokenTypeiscustomer, so an admin token falls through and leaves the request anonymous here. - Storefront session cookie — the signed session cookie written when a customer logs in through the storefront. Its name comes from
system.session.cookieNameand defaults tosid. The session row is read straight from the database and the customer must still havestatus = 1.
Whichever path matched, the resolved account is what getCurrentCustomer() returns, and the handlers never read a customer id from the URL or the request body.
curl -X PATCH \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <customer JWT>" \
--data-raw '{"full_name":"John A. Smith"}' \
https://<your domain>/api/customers/me
Endpoints
Update My Profile
Updates the calling customer's own record. Send only the fields you want to change.
The payload schema declares full_name and email but sets additionalProperties: true, so extension columns flow through untouched. Three fields are deleted from the body before the write no matter what you send:
| Stripped field | Why |
|---|---|
password | Changing it goes through POST /api/customers/password, which verifies a reset token. |
group_id | Self-promotion into another customer or pricing group. |
status | Re-activating a disabled account. |
If those are the only fields you sent, the request fails with 400 and There is nothing to update. Changing email to an address already owned by another customer fails with 400 and Email is already used.
| Field Name | Field Type | Required |
|---|---|---|
| full_name | string | No |
| string | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/customers/me
fetch('https://<your domain>/api/customers/me', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"customer_id": 21,
"uuid": "433ba97f-8be7-4be9-be3f-a9f341f2b89f",
"status": 1,
"group_id": 1,
"email": "john.smith@example.com",
"full_name": "John A. Smith",
"created_at": "2025-02-07T14:18:05.000Z",
"updated_at": "2025-02-07T14:22:41.000Z",
"links": [
{
"rel": "self",
"href": "/api/customers/me",
"action": "PATCH",
"types": [
"application/json"
]
}
]
}
}
The password column is removed from the returned row.
Create My Address
Adds an address to the calling customer's address book. customer_id is taken from the authenticated context; customer_id, customer_address_id, uuid and address_id are deleted from the body before the insert, so an attacker cannot graft an address onto another account.
Setting is_default to true clears the flag on every other address belonging to the same customer, in the same transaction.
The body uses the shared address vocabulary (the same column names as cart and order addresses). Which fields are required depends on the country — see Address Validation below.
| Field Name | Field Type | Required |
|---|---|---|
| recipient | string or null | No |
| given_name | string or null | No |
| family_name | string or null | No |
| organization | string or null | No |
| address_line_1 | string or null | No |
| address_line_2 | string or null | No |
| address_line_3 | string or null | No |
| dependent_locality | string or null | No |
| locality | string or null | No |
| administrative_area | string or null | No |
| postal_code | string or null | No |
| sorting_code | string or null | No |
| country | string | Yes |
| telephone | string or null | No |
| extra | object or null | No |
| is_default | boolean or number or string or null | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/customers/me/addresses
fetch('https://<your domain>/api/customers/me/addresses', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"customer_address_id": 42,
"uuid": "a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"customer_id": 21,
"recipient": "John Smith",
"given_name": null,
"family_name": null,
"organization": null,
"address_line_1": "123 Main St",
"address_line_2": null,
"address_line_3": null,
"dependent_locality": null,
"locality": "New York",
"administrative_area": "US-NY",
"postal_code": "10001",
"sorting_code": null,
"country": "US",
"telephone": "+15550100",
"extra": {},
"is_default": true,
"created_at": "2025-02-07T14:18:05.000Z",
"updated_at": "2025-02-07T14:18:05.000Z",
"links": [
{
"rel": "edit",
"href": "/api/customers/me/addresses/a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"action": "UPDATE",
"types": [
"application/json"
]
},
{
"rel": "delete",
"href": "/api/customers/me/addresses/a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"action": "DELETE",
"types": [
"application/json"
]
}
]
}
}
action: "UPDATE" is not a typo you should copyThe edit link reports "action": "UPDATE". The endpoint is a PATCH. Follow the href, not the action.
Update My Address
Updates one address that belongs to the calling customer. {address_id} is the address uuid.
The handler loads the row with uuid = {address_id} AND customer_id = <current customer> before writing anything. An address that exists but belongs to someone else is indistinguishable from one that does not exist: both answer 400 with Invalid address. The same four ownership columns (customer_id, customer_address_id, uuid, address_id) are stripped from the body.
A partial patch is safe: the service merges your fields over the stored row and validates the merged address against the country's schema, so sending only locality does not trip a required-field rule. Country-scoped extra fields of the stored row survive a patch that omits country. Setting is_default to true here also clears the flag on the customer's other addresses.
| Field Name | Field Type | Required |
|---|---|---|
| recipient | string or null | No |
| given_name | string or null | No |
| family_name | string or null | No |
| organization | string or null | No |
| address_line_1 | string or null | No |
| address_line_2 | string or null | No |
| address_line_3 | string or null | No |
| dependent_locality | string or null | No |
| locality | string or null | No |
| administrative_area | string or null | No |
| postal_code | string or null | No |
| sorting_code | string or null | No |
| country | string | No |
| telephone | string or null | No |
| extra | object or null | No |
| is_default | boolean or number or string or null | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/customers/me/addresses/a1b2c3d4-e5f6-4890-abcd-ef1234567890
fetch('https://<your domain>/api/customers/me/addresses/a1b2c3d4-e5f6-4890-abcd-ef1234567890', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"customer_address_id": 42,
"uuid": "a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"customer_id": 21,
"recipient": "John A. Smith",
"given_name": null,
"family_name": null,
"organization": null,
"address_line_1": "456 Oak Ave",
"address_line_2": null,
"address_line_3": null,
"dependent_locality": null,
"locality": "New York",
"administrative_area": "US-NY",
"postal_code": "10001",
"sorting_code": null,
"country": "US",
"telephone": "+15550100",
"extra": {},
"is_default": true,
"created_at": "2025-02-07T14:18:05.000Z",
"updated_at": "2025-02-07T15:02:19.000Z",
"links": [
{
"rel": "edit",
"href": "/api/customers/me/addresses/a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"action": "UPDATE",
"types": [
"application/json"
]
},
{
"rel": "delete",
"href": "/api/customers/me/addresses/a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"action": "DELETE",
"types": [
"application/json"
]
}
]
}
}
Delete My Address
Permanently removes one address that belongs to the calling customer. {address_id} is the address uuid, and the same ownership check applies. The deleted row is echoed back — with no links array, unlike the create and update responses.
- cURL
- JavaScript
curl
-H "Accept: application/json"
https://<your domain>/api/customers/me/addresses/a1b2c3d4-e5f6-4890-abcd-ef1234567890
fetch('https://<your domain>/api/customers/me/addresses/a1b2c3d4-e5f6-4890-abcd-ef1234567890', {
headers: {
'Accept': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"customer_address_id": 42,
"uuid": "a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"customer_id": 21,
"recipient": "John A. Smith",
"given_name": null,
"family_name": null,
"organization": null,
"address_line_1": "456 Oak Ave",
"address_line_2": null,
"address_line_3": null,
"dependent_locality": null,
"locality": "New York",
"administrative_area": "US-NY",
"postal_code": "10001",
"sorting_code": null,
"country": "US",
"telephone": "+15550100",
"extra": {},
"is_default": true,
"created_at": "2025-02-07T14:18:05.000Z",
"updated_at": "2025-02-07T15:02:19.000Z"
}
}
Address Validation
The payload schemas above only check the shape of the keys and require country. Everything else is validated by the createCustomerAddress / updateCustomerAddress services against the country's address schema — the same addressSchema(country) the storefront form renders from — after the input has been normalized (trimmed, country upper-cased, telephone converted to E.164 with the country's dial code).
| Check | code | Example message |
|---|---|---|
| A key that is neither a shared column nor a registered extra field | unknown_field | province is not a known address field |
| A field the country requires (or the store requires through the address settings) is empty | required | ZIP code is required |
| A value does not match the country's pattern (postal code, telephone, an extra field's pattern) | pattern | ZIP code is not valid |
| A region key the country's provider does not list as active, at any enumerated level | region_invalid | State is not a valid region |
| A value of the wrong JSON type | type | Company has the wrong type |
| A country outside the merchant's sell-to list | country_not_allowed | We do not sell to Narnia |
A rule added by an extension with addAddressValidationRule | the rule's own code | the rule's own message |
Messages are translated into the request locale with the field's label interpolated, so a storefront can show them next to the input.
Failures answer 400 with every failing field at once:
{
"error": {
"status": 400,
"message": "Invalid address",
"errors": [
{ "field": "postal_code", "code": "pattern", "message": "ZIP code is not valid" },
{ "field": "telephone", "code": "required", "message": "Telephone is required" }
]
}
}
Extensions add rules from bootstrap.ts with addAddressValidationRule; the rules run after the schema checks, with the resolved schema as their second argument. The field vocabulary, region keys and the eight store settings that change what is required are explained in the Address formats guide.
Error Responses
| Status | When |
|---|---|
401 | No customer JWT and no valid storefront session. Message: You must be logged in to update your profile or You must be logged in to manage your addresses. |
400 | Nothing left to update, duplicate email, or an address uuid that is not owned by the caller. |
500 | Address validation failure, or an unexpected error. The service message is passed through verbatim. |
All errors use the standard envelope:
{
"error": {
"status": 400,
"message": "Invalid address"
}
}
Reading Your Own Data
There is no GET /api/customers/me. Read the current customer, their address book, and their orders through GraphQL — the storefront schema exposes them on the authenticated currentCustomer field. See the data fetching documentation.