Postcode capabilities
How delivery and installation availability is resolved from postcode_capabilities metaobjects.
Postcode capabilities
๐ Dev ยท ๐ค Ops (reference)
When a customer enters a postcode, what they're allowed to do in that area - delivery and/or at-home
installation - is driven by postcode_capabilities metaobjects, matched by postcode. See
Delivery zones for the customer-facing behaviour.
How it resolves
- The theme sends the postcode to the backend (
/v1/postcode/{postcode}). - The backend matches it against the
postcode_capabilitiesmetaobjects and enriches the response with that area's capabilities. - The browser receives the result and stores it. Crucially, the storefront just gets
deliveryandinstallation_serviceas booleans (plus the suburb/state and installer calendar URL) - all the matching logic lives server-side. See The backend API.
Because the matching and enriching happen in the backend, the theme never decides eligibility itself - it only reads the booleans it's handed and updates the UI (delivery messaging, installation CTA).
The postcode_capabilities metaobject
Managed under Shopify Admin โ Content โ Metaobjects โ Postcode capabilities. To change what an area can do, edit or add an entry - no code change required. See Metaobjects.
| Field (key) | Name | Type | Meaning |
|---|---|---|---|
display_name | Display Name | Text (single line) | Friendly location name shown to the customer. |
suburb_name | Suburb Name | Text (single line) | The suburb for this entry. |
postcode | Postcode | Text (single line) | Comma-separated postcode patterns this entry matches - see Defining ranges. |
installation_service | Installation Service | True/false | Whether at-home installation is available in this area. |
delivery | Delivery | True/false | Whether delivery is available in this area. |
installer_calendar_url | Installer calendar IDs | Text (list) | The installer calendar ID(s) used for this area's bookings. |
Range patterns require the postcode field to be a single-line text field. If the definition still has it as
Number (integer), it will reject values like 3000,30* - and Shopify cannot change a field's type in place. Migrate
by adding a new text field (or deleting and re-creating postcode, which discards its existing values) before
bulk-loading patterns.
This is now the source of truth for delivery and installation availability - not a hard-coded list. To open or
close an area, update its postcode_capabilities entry in admin (or have the backend team confirm the entry exists
for that postcode).
Defining ranges with commas and wildcards
The postcode field holds a comma-separated list of patterns. The backend splits the value on commas and scans the
entire list of entries for matches. A trailing * is a prefix wildcard: 30* matches any postcode starting with
30.
| Example value | Matches |
|---|---|
3000 | Exactly 3000. |
3000,3004,3006 | Any of the three listed postcodes. |
30* | 3000โ3099. |
30*,31* | 3000โ3199. |
3* | 3000โ3999 (all of VIC). |
3000,32*,3977 | 3000, anything in 3200โ3299, and 3977. |
Avoid overlapping entries. Because every entry is scanned, a postcode can match more than one - e.g. an entry with
30* and another with 3000. Which entry wins is decided by the backend's precedence rule, so either keep patterns
mutually exclusive or confirm the intended precedence (e.g. exact match beats wildcard) with the backend team before
relying on overlaps.
Bulk-creating entries with GraphQL
For loading many areas at once, use the Admin API's bulk mutation pipeline instead of creating entries one by one in
admin. Requires an Admin API token with the write_metaobjects scope.
Prefer the import tool. We provide a small web app at https://cbp-postcode-importer.tools.croproai.com/ that runs this exact procedure for you: drop a JSONL file and it validates every line (pattern syntax, handles, booleans, overlapping ranges) before staging, uploading, running the bulk mutation, and reporting per-line results. It also provides a downloadable sample JSONL to start from.
Build a JSONL file
One line per entry. Every field value is a string: booleans as "true"/"false", the calendar ID list as a
JSON-encoded array string. Handles must be unique - with ranges, entries describe zones rather than single suburbs, so
name them accordingly:
{"handle": {"type": "postcode_capabilities", "handle": "melbourne-metro"}, "metaobject": {"fields": [{"key": "display_name", "value": "Melbourne Metro"}, {"key": "suburb_name", "value": "Melbourne"}, {"key": "postcode", "value": "3000,3004,30*,31*"}, {"key": "installation_service", "value": "true"}, {"key": "delivery", "value": "true"}, {"key": "installer_calendar_url", "value": "[\"cal-id-melb-1\",\"cal-id-melb-2\"]"}]}}
{"handle": {"type": "postcode_capabilities", "handle": "mornington-peninsula"}, "metaobject": {"fields": [{"key": "display_name", "value": "Mornington Peninsula"}, {"key": "suburb_name", "value": "Mornington"}, {"key": "postcode", "value": "3915,3930,3931,393*,394*"}, {"key": "installation_service", "value": "false"}, {"key": "delivery", "value": "true"}, {"key": "installer_calendar_url", "value": "[]"}]}}Stage the upload
mutation StageBulkUpload {
stagedUploadsCreate(
input: [
{
resource: BULK_MUTATION_VARIABLES
filename: "postcode_capabilities.jsonl"
mimeType: "text/jsonl"
httpMethod: POST
}
]
) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
userErrors {
field
message
}
}
}The response's stagedTargets[0] gives you a url to POST to and a parameters array. Those parameters are
name/value pairs Shopify signs for its storage backend (Google Cloud Storage, for bulk variables). Treat them as
opaque: don't hardcode, drop, or reorder them. Replay each one as its own multipart form field, in the order
returned, then add the file last (Google Cloud Storage rejects the upload if file isn't the final field).
The exact set varies by API version, so always read them from the response rather than assuming. Typical pairs you'll see:
Parameter (name) | What its value is |
|---|---|
key | The object path in storage. This is also the value you pass as stagedUploadPath in the next step. |
Content-Type / mime_type | Must match the file โ text/jsonl. |
success_action_status | The HTTP status the storage returns on success (e.g. 201). |
acl | Object access control (e.g. private). |
policy | A base64-encoded, signed upload policy. |
x-goog-algorithm, x-goog-credential, x-goog-date, x-goog-signature | Google Cloud Storage signature fields authorising this one POST. |
Rather than copy them by hand, build the -F flags straight from the response (saved here as staged.json):
url=$(jq -r '.data.stagedUploadsCreate.stagedTargets[0].url' staged.json)
# One -F per returned parameter, preserving order:
mapfile -t fields < <(jq -r \
'.data.stagedUploadsCreate.stagedTargets[0].parameters[] | "\(.name)=\(.value)"' staged.json)
args=(); for f in "${fields[@]}"; do args+=(-F "$f"); done
# Parameters first, file LAST:
curl -X POST "$url" "${args[@]}" -F "file=@postcode_capabilities.jsonl"The stagedUploadPath for the next step is the key parameter's value:
jq -r '.data.stagedUploadsCreate.stagedTargets[0].parameters[]
| select(.name == "key") | .value' staged.jsonRun the bulk mutation
Uses metaobjectUpsert keyed on handle, so re-running the same file updates entries instead of duplicating them -
safe for corrections.
mutation BulkCreatePostcodeCapabilities {
bulkOperationRunMutation(
mutation: """
mutation CreateEntry($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
metaobject { id handle }
userErrors { field message code }
}
}
"""
stagedUploadPath: "<key-parameter-value-from-previous-step>"
) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}Poll until complete
query BulkStatus {
currentBulkOperation(type: MUTATION) {
id
status
objectCount
errorCode
url
partialDataUrl
}
}When status is COMPLETED, download the url - a JSONL of per-line results. Check each line's userErrors: the
bulk operation reports success even when individual upserts failed (e.g. a pattern value rejected by a still-integer
postcode field).
Notes:
- Only one bulk mutation runs at a time per shop; the JSONL file is capped at 20ย MB.
- To verify afterwards, list the entries:
metaobjects(type: "postcode_capabilities", first: 50).