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.
| Field Name | Field Type | Required |
|---|---|---|
| full_name | string | Yes |
| telephone | string | No |
| address_1 | string | Yes |
| address_2 | string | No |
| city | string | No |
| province | string | Yes |
| country | string | Yes |
| postcode | string | Yes |
| is_default | boolean | 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,
"full_name": "John Smith",
"telephone": "+1 555 0100",
"address_1": "123 Main St",
"address_2": null,
"postcode": "10001",
"city": "New York",
"province": "US-NY",
"country": "US",
"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, so sending only city does not trip the "Full name is required" rule. Setting is_default to true here also clears the flag on the customer's other addresses.
| Field Name | Field Type | Required |
|---|---|---|
| full_name | string | No |
| telephone | string | No |
| address_1 | string | No |
| address_2 | string | No |
| city | string | No |
| province | string | No |
| country | string | No |
| postcode | string | No |
| is_default | boolean | 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,
"full_name": "John A. Smith",
"telephone": "+1 555 0100",
"address_1": "456 Oak Ave",
"address_2": null,
"postcode": "10001",
"city": "New York",
"province": "US-NY",
"country": "US",
"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,
"full_name": "John A. Smith",
"telephone": "+1 555 0100",
"address_1": "456 Oak Ave",
"address_2": null,
"postcode": "10001",
"city": "New York",
"province": "US-NY",
"country": "US",
"is_default": true,
"created_at": "2025-02-07T14:18:05.000Z",
"updated_at": "2025-02-07T15:02:19.000Z"
}
}
Address Validation
The three address endpoints have no payloadSchema.json. Nothing is validated by AJV at the router level — validation happens inside the createCustomerAddress / updateCustomerAddress services, driven by the shared addressValidator rule set:
| Rule | Message |
|---|---|
full_name present and not blank | Full name is required |
address_1 present and not blank | Address is required |
province present and not blank | Province is required |
country present and not blank | Country is required |
postcode present and not blank | Postcode is required |
Extensions add to this list from bootstrap.ts with addAddressValidationRule(...), so a live store may enforce more than the five rules above.
500, not 400A failed address rule is thrown by the service and caught by the generic catch in the handler, which answers 500 with the joined rule messages as the error text (for example Full name is required, Postcode is required). Only the ownership and authentication checks produce 400 / 401. Treat a 500 from these endpoints as "possibly your payload" rather than "the server is broken".
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.