Prerequisites
You’ll need:- A Text account with permission to create personal access tokens.
- A Shopify Dev Dashboard account, to create one or more stores and the app that connects them.
- Node.js 22 or later, to run the service.
- ngrok, to give the local service a public HTTPS address during setup and testing.
- Git, to clone the example repository.
How an event becomes a ticket
The service subscribes to three Shopify webhook topics per store:orders/create, refunds/create, and fulfillment_events/create. For each delivery, the service does the following:
- Identifies which store sent the event and verifies Shopify as the sender.
- Looks up the order’s financial status, fraud risk assessment, and customer lifetime spend with one Shopify Admin GraphQL call. None of that data is in the webhook payload itself.
- Checks the enriched event against the routing table to decide a team, priority, and tags.
- Creates the ticket through the ticketing API.
Set up the Shopify side
You need one Shopify store per entry you’ll add to the service’s configuration. Two stores are enough to see multi-store routing at work.Create your Shopify stores
config/stores.json later — for example, acme-main for a store key of main and acme-outlet for outlet — so the two stay easy to tell apart. Note each store’s .myshopify.com domain from its admin URL.Create a Shopify app

read_orders, read_customers, and read_fulfillments. Add write_fulfillments too if you want to simulate a failed delivery for testing, since Shopify’s admin has no button for that event.
Install the app and copy its credentials
Set up the Text side
Create a personal access token
accounts--my:ro scope — this is the scope the ticketing API expects for authentication.Note your account ID, shown next to the token. The service sends the token as HTTP Basic auth, with the account ID as the username and the token as the password.Clone and configure the project
config/stores.json, keep one entry per Shopify store you created and remove the rest. Set each entry’s key to the name you gave the store and shopDomain to its real domain. In our example:credentialsKey points a store at its SHOPIFY_<credentialsKey>_CLIENT_ID and SHOPIFY_<credentialsKey>_CLIENT_SECRET pair in .env — the name itself doesn’t matter, as long as it matches. Use the same key for every store on the same Shopify app; give a store a different key only if it uses a different app.In .env, set TEXT_ACCOUNT_ID, TEXT_PAT, SHOPIFY_ACME_CLIENT_ID, and SHOPIFY_ACME_CLIENT_SECRET from the values you copied — the ACME suffix matches the credentialsKey you kept in config/stores.json.Leave PUBLIC_URL empty for now — you’ll set it after starting a tunnel.Provision teams, tags, and custom fields
shopify_order_id and shopify_order_url. The script prints one line per resource and is safe to rerun — it doesn’t touch anything that already exists.Each tag is either shared with every team or scoped to one team. The script creates missing tags as shared, because the rule tags a VIP escalation keeps on the ticket (payment-review, refund, delivery-issue) and the per-store tags need to be visible to whichever team the ticket lands in.If a tag your rules need already exists but is scoped to a different team, the script names it and exits — widen that tag to all teams in the Text app, then rerun the script.Run the service and expose it to Shopify
Shopify only delivers webhooks to a public HTTPS address, so a local run needs a tunnel in front of it.Start the service
Expose the service with ngrok
https forwarding address ngrok prints, without a trailing slash, into .env as PUBLIC_URL. Restart npm run dev with the same command — the watcher restarts automatically on source and config/stores.json changes, but not on .env changes, so a manual restart is always required after editing it.ngrok’s free plan issues a new forwarding address every time the tunnel restarts. When that happens, update PUBLIC_URL, restart npm run dev, and rerun the next step so Shopify’s subscriptions point at the current address. ngrok’s local web interface shows every request it forwards at http://127.0.0.1:4040 while the tunnel runs, which is useful while testing.If the service already runs somewhere with a stable public HTTPS address, skip ngrok and set PUBLIC_URL to that address directly.Register the webhooks
PUBLIC_URL. A successful run prints one created line per store and topic; rerunning it prints exists. Because these subscriptions are created through the API rather than the Shopify admin UI, this command is also how you inspect them — they don’t appear on the store’s Notifications page.Test the result
During testing, keep thenpm run dev terminal visible — each delivery logs one JSON line naming the matched rule and ticket.
In one store’s admin, create an order with a customer email, choose Payment due later, and place it. The log should show "rule":"pending-payment", and a new high-priority ticket should appear in Payments, tagged with the store’s key and payment-review, with the customer as requester and a link to the order.
Create another order in the same store and mark it paid immediately instead of leaving payment pending. The log should show no ticket needed — a paid, low-risk order matches no rule.
That confirms the core flow, both when a ticket should exist and when it shouldn’t. The scenarios below check the other rules.
Test a refund
Test a refund
"rule":"refund", and a ticket should
appear in Returns & Refunds at medium priority, quoting the refunded
amount.Test multi-store routing
Test multi-store routing
Test the VIP override
Test the VIP override
vipThreshold in config/stores.json to a value below one of
its customers’ lifetime spend — the dev server picks up the change on its own
— then create a pending-payment or refund order for that customer. The log
should show the rule name suffixed with +vip, and the ticket should land in
the VIP team at urgent priority.Test a delivery failure
Test a delivery failure
id as a full gid://shopify/Fulfillment/... value — copy it as-is into <fulfillment_gid>.The log should show "rule":"delivery-issue", and the ticket should land in the store’s shipping team with the carrier message in its body.HIGH there. src/routing/rules.test.ts
covers it directly.Use ticket context beyond just routing
Every ticket this service creates already carries theshopify_order_id and shopify_order_url custom fields, plus tags like payment-review or delivery-issue naming the rule that matched.
Once a value is on the ticket, you can use it outside this service too. A payment issue from any store routes to the same Payments team. You can use List tickets filtered on payment-review to pull every open payment issue from every store into one list, instead of checking each store’s queue separately.
The New tickets report takes the same tagIDs and teamIDs filters, but for trend analysis. Filter the request by the delivery-issue tag to chart delivery-failure volume per store over time.
A ticketing rule can trigger on a tag this service sets, too — send an automatic follow-up message if a delivery-issue ticket goes unanswered for a day.
To add another Shopify detail to a ticket, follow the same path this data already takes: read the value from the Admin API in src/shopify/admin.ts, then write it onto the ticket as a field or a tag in src/ticketing/build-ticket.ts. Which additional Shopify data is worth adding this way, and what you build on top of it, is up to your team.
Adapt the routing rules
src/routing/rules.ts holds the routing table as an ordered list: the first rule whose condition matches decides the ticket’s team, priority, and tags, and the VIP override in the same file can then replace that outcome. Each rule reads from the enriched event (src/shopify/admin.ts’s getOrderContext) and the store’s configuration, so a new condition on an event this sample already handles — a different spend threshold, an additional fulfillment status, a new team — is a change to that one file.
src/ticketing/build-ticket.ts decides what goes on the ticket itself, including the custom fields and the requester.
Routing on a Shopify event this sample doesn’t subscribe to at all is a wider change, touching three files:
- Add the topic and its payload schema to
src/shopify/schemas.ts. - Add its GraphQL enum spelling to
TOPIC_ENUMinscripts/register-webhooks.ts. - Add the rule that reads it to
rules.ts.
npm test, npm run typecheck, and npm run lint, which cover the routing table, the ticket builder, the webhook handler, and the Shopify API clients — run them after any change to src/.
A rule only has data to work with if something already put it on the enriched event or the store configuration. If you want to route on a Shopify signal this sample doesn’t already fetch, extend the Admin GraphQL query in src/shopify/admin.ts alongside the rule that reads it.
Troubleshooting
Prepare the service for production
This sample demonstrates the integration end to end, but three choices need to change before you run it against real orders:Replace in-memory deduplication
Replace in-memory deduplication
src/dedupe.ts keeps seen webhook IDs in a process-local set. A restart
forgets them, so a redelivery after a restart creates a second ticket. Replace
it with Redis or a database so deduplication survives restarts and works
across more than one instance.Acknowledge Shopify within 5 seconds
Acknowledge Shopify within 5 seconds
Keep credentials out of the repository and rotate them
Keep credentials out of the repository and rotate them
.env and config/stores.json are already excluded from Git. Use your
deployment platform’s secrets manager instead of files on disk, and rotate the
Shopify client secret and the Text personal access token like any other
credential — both are read once, at startup.Keep exploring