Building an App, Step by Step
A worked example from an empty directory to an installed app, with the decisions explained at each step.
We will build a small app that adds a delivery estimate to the checkout, so a shopper sees "arrives Tuesday" rather than nothing.
It is deliberately small, and it exercises every part: installation, a token, reading shop data, a checkout extension, a webhook, and uninstalling cleanly.
Step 1: Decide What It Needs
Before any code, write down the permissions and be mean about it.
Our app needs to read products, because the estimate depends on what is in the basket. It does not need customers, orders or inventory.
The permission list is a sales document
A merchant sees it at install. An app asking for customer data to show a delivery date is an app that does not get installed by anybody paying attention.
Step 2: Stand Up Somewhere to Receive Calls
Your app is a web service. At minimum it needs three addresses:
POST /install the shop tells you a merchant installed you
POST /render the shop asks you for the checkout block
POST /webhook the shop tells you something happened
All three must be HTTPS and answer quickly.
Step 3: Handle the Install
When a merchant installs your app you receive an access token for their shop. Store it against the shop, encrypted.
// Keep one row per installation. Never one token for all shops.
Installation::updateOrCreate(
['shop_domain' => $request->input('shop_domain')],
['access_token' => encrypt($request->input('access_token')), 'active' => true],
);
Then confirm which shop you are actually in, rather than trusting the request:
curl -s https://shop.example.com/api/apps/shop \
-H "X-UnifyCart-Access-Token: THE_TOKEN"
Never trust a shop identifier that arrived in a request body
The token is the only thing that proves which shop a call concerns. An app that reads the shop from a parameter can be made to act on somebody else's.
Step 4: Register Your Extension
Still during install, tell the shop where you want to appear.
curl -s -X POST https://shop.example.com/api/apps/extensions \
-H "X-UnifyCart-Access-Token: THE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "checkout_block",
"callback_url": "https://your-app.example.com/render",
"timeout_ms": 2000
}'
We set a 2000ms timeout rather than the 5000 default, because we would rather vanish than delay somebody's checkout.
Step 5: Subscribe to What You Need
curl -s -X POST https://shop.example.com/api/apps/webhooks \
-H "X-UnifyCart-Access-Token: THE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "topic": "order.placed" }'
We want order.placed so we can record what we estimated against what was actually ordered, and improve.
Subscribe to what you use. Every unused subscription is work the shop does for nothing.
Step 6: Render Something
The shop calls your callback_url during checkout and expects a quick answer.
public function render(Request $request)
{
// Verify the call really came from the shop before doing anything.
abort_unless($this->signatureValid($request), 401);
$shop = $this->shopFor($request);
$estimate = $this->cachedEstimateFor($shop, $request->input('items', []));
return response()->json([
'html' => view('block', ['estimate' => $estimate])->render(),
]);
}
Note what this does not do: it does not call the shop's API, and it does not calculate anything slow. Both would put your latency in front of a shopper. The estimate was worked out earlier and cached.
Step 7: Do the Slow Work Elsewhere
Our estimate depends on product data. Fetch it on a schedule, not during a render.
curl -s "https://shop.example.com/api/v1/products?per_page=100" \
-H "Authorization: Bearer THE_TOKEN"
Page through once, keep what you need, and refresh on a sensible interval. Then use order.placed to learn about changes rather than re-reading the catalogue hourly.
Step 8: Handle the Webhook Properly
public function webhook(Request $request)
{
abort_unless($this->signatureValid($request), 401);
// Answer first, work afterwards. A slow handler is a failed delivery.
dispatch(new RecordOrder($request->all()));
return response()->noContent();
}
Then, in the job, treat it as something that may run twice:
// Deliveries are retried, so the same event can arrive after you handled it.
if (ProcessedEvent::where('event_id', $payload['id'])->exists()) {
return;
}
Step 9: Test It Against a Real Shop
Install it on a shop with a real catalogue. Then, deliberately:
- Put forty items in a basket and check your render still answers in time
- Make your service return a 500 and confirm checkout still completes without your block
- Make your service time out and confirm the same
- Send yourself the same webhook twice and confirm nothing is recorded twice
The second and third are the ones that get skipped and the ones that take a merchant's shop down.
Step 10: Uninstall Cleanly
When the merchant removes your app, remove what you added.
curl -s -X DELETE https://shop.example.com/api/apps/extensions/{id} \
-H "X-UnifyCart-Access-Token: THE_TOKEN"
Then stop calling the shop, mark the installation inactive, and cancel any scheduled work for it.
Test the uninstall as carefully as the install
Install, use the shop, uninstall, and then look at the shop. It should be exactly as it was. An extension or script tag left behind by an app that is gone is the usual cause of a layout fault after an uninstall, and the merchant will blame the platform rather than you.
Step 11: Decide What Happens to Data
Say in your listing what you keep after an uninstall and for how long.
A merchant who uninstalls and finds your data still attached to their products, or who reinstalls and finds their settings gone, will say so publicly. Either answer is acceptable. Not having one is not.
What This Example Left Out
Billing, if you charge. Settings, if the merchant configures anything. Multiple extension points. And translations, which matter the moment a shop is not in English.
The shape stays the same: install, store one token per shop, register what you need, answer fast, verify everything, and remove it all cleanly.
