Your own client portal
Tasks, files, approvals, and history in one place.
Frontend & Backend Web Dev on Subscription
Queue as many tasks as you want — bugs, new pages, plugin updates, whatever's sitting in your backlog. No quotes to approve, no scope calls, no invoices to chase.
We complete 3 real tasks for free · no card · 7 days trial
Tasks, files, approvals, and history in one place.
Focused delivery, based on your plan.
We build, QA, and publish every change.
Every result, file, and commit stays with you.
Test the service on your actual backlog.
The free test ends unless you choose a plan.
Where work happens
Add and prioritize tasks, attach files, answer questions, approve releases, and keep the complete change history in one place.
Explore the live demoA task, start to finish
A representative delivery based on the kind of production request we handle. Names and details are illustrative, but the workflow is true to the portal.
Customers occasionally received two orders after retrying a declined card payment.
What: made the Stripe webhook the order source of truth and added idempotency protection.
Where: checkout retry and order creation flow.
Deploy: 14 checkout paths passed; published after client approval.
Follow-up: production monitoring and rollback point saved.
Start with real work
Start on this page with the work already sitting in your backlog.
Your tasks carry over to registration, so you never have to enter them twice.
Your workspace opens in live mode with the real queue already there. No demo data.
The free test ends after 3 completed tasks or 7 days. Nothing renews and no card is charged automatically.
Not ready to add a task? Continue to the portal and explore the portal first. No card required.
Your tasks will appear here before you create an account.
Reviewing your task. Please, wait... It could take up to 60 seconds.
What we can help with
From a focused bug to a connected product feature, the same team can handle the interface, backend, integrations, quality checks, and release.
Interfaces, services, business logic, and end-to-end features.
Reliable connections between your product and external systems.
Faster loading, leaner code, and smoother user journeys.
Sign-in flows, permissions, secure sessions, and access reviews.
Practical schemas, migrations, queries, and data workflows.
Payments, CRM, analytics, email, CMS, and automation tools.
Focused iOS and Android product work from one codebase.
Clear internal tools for content, operations, and reporting.
Evidence-led reviews with fixes prioritized by impact.
Useful AI features connected safely to your existing product.
Live tracking
This live view updates from our delivery backend. Each square is one day: a bug fixed, a page shipped, a plugin updated. The chart makes the delivery rhythm visible over time.
tasks closed
tasks closed
average active work time
tasks completed · last 12 months · by day tasks · 12mo · daily
Loading task history
Task examples
Send us a single fix or a larger outcome. Small tasks go straight into the queue. Complex work is scoped, broken into clear steps, and then delivered through the same queue.
Focused work that can usually be handled as one task from start to finish.
Fix a broken form or missing notification
Update pricing, copy, images, or brand assets
Repair mobile spacing and responsive layout issues
Update WordPress plugins and themes safely
Add GA4, GTM, pixels, or conversion events
Create redirects, metadata, and a custom 404 page
Add a Shopify, Webflow, or Framer page section
Patch a focused React or Next.js component bug
We turn larger work into smaller, reviewable tasks before development starts.
Build a campaign landing page from Figma through launch
Redesign and rebuild a multi-page marketing website
Create a new Shopify storefront experience or theme flow
Migrate a website between WordPress, Webflow, or a custom stack
Connect forms, CRM, analytics, email, and automation systems
Build a custom interactive feature, calculator, or client portal
Rebuild a checkout or subscription journey with analytics and QA
Create a multilingual content system and reusable page library
Maintain runs one active task at a time. Grow runs up to two. You always see what is being worked on now and which step comes next.
Case files
Real website work rarely arrives as a perfect specification. These case files show how we turn a rough message into a safe technical decision, a reviewable change and evidence that it works.
Anonymized case files based on recurring real-world briefs. Names, URLs and identifying details are changed; these examples are not presented as customer testimonials. Code excerpts are shortened, sanitized and contain no client secrets.
“Hey — tiny emergency. Since yesterday the pay button sometimes spins forever, but we still see an order later. Buyers are writing because no confirmation arrives. Please don’t restyle anything, we’re taking orders right now.”
The first completed order made the report confusing: money could be authorised while the customer still saw a frozen button. A browser trace showed that the first checkout request returned successfully. The second and third requests came from the theme itself, after WooCommerce recalculated shipping for a changed postcode.
Inside the child theme, every updated_checkout event attached another anonymous submit handler. After a customer edited an address twice, three handlers were waiting. We reproduced that sequence on staging, removed the accumulated handlers and bound one namespaced listener to WooCommerce’s supported event bus. The missing email was a second fault, not part of checkout: a stalled Action Scheduler job was holding the transactional queue. We cleared it separately, replayed test messages and released only the child-theme patch.
child-theme/assets/js/checkout.js JavaScript $(document.body).on('updated_checkout', () => {
$('form.checkout').on('submit', lockPayButton);
$('.order-total').html(readThemeTotal());
}); updated_checkout runs whenever shipping, address or payment state changes. This code added a fresh submit listener every time and never removed the previous one.
const $bus = $(document.body);
const $form = $('form.checkout');
$form.off('submit', lockPayButton);
$form.on('submit.tundraCart', lockPayButton);
$bus.on('checkout_error.tundraCart', releasePayButton);
$bus.on('updated_checkout.tundraCart', syncOrderSummary); The listener has a namespace, is installed once and uses WooCommerce’s recalculated server total instead of maintaining a second total in theme code.
We did not edit WooCommerce or the payment plugin. Keeping the patch in the child theme meant the next plugin update could not overwrite it, and namespacing gave future developers a safe way to replace or remove only this behaviour.
A full rollback would also have removed legitimate orders and content created after the update. Isolating the duplicate listener preserved current data and kept the payment integration on its supported version.
Guest and account checkout completed once per click across six payment, coupon and shipping combinations. Confirmation messages left the queue, the visual design stayed untouched, and the release note included the exact rollback file.
“We put the bundle widget into the new theme and the side cart now flashes open two times — I mostly catch it on my phone. Analytics also thinks people add way more products than they do. Campaign traffic starts Monday morning.”
On a fast desktop both cart drawers painted almost on top of each other, so the defect looked like a small flicker. Network throttling exposed two separate POST requests: the bundle widget sent one, then the theme’s delegated submit handler sent the same variant again. Tracking was duplicated a second way — once on button click and again when the drawer received cart:updated.
Rather than special-case the widget, we gave standard product forms and bundle forms one addCartLines function. It became the only place allowed to mutate the cart. After Shopify accepted the request, the adapter emitted one cart:changed event with a request ID. The drawer refreshed from that event and GTM used the same ID for deduplication. We then ran variants, bundles, discounts, sold-out states and rapid double taps through the same path.
assets/cart-actions.js JavaScript productForm.addEventListener('submit', themeAddToCart);
bundleWidget.on('added', themeAddToCart);
addButton.addEventListener('click', trackAddToCart);
document.addEventListener('cart:updated', trackAddToCart); Two components owned the cart request and two unrelated signals owned analytics. A single customer action could therefore create two products and two events.
export async function addCartLines(lines, requestId) {
const response = await fetch('/cart/add.js', cartRequest(lines));
if (!response.ok) throw new CartError(await response.json());
document.dispatchEvent(new CustomEvent('cart:changed', {
detail: { requestId }
}));
} Every entry point calls one mutation function. UI and analytics react only to its confirmed success event, carrying the same request ID.
A CSS fix could hide the second drawer but would leave the duplicate request and corrupt reporting. Consolidating ownership of the cart transaction fixed the data and the interface at the same boundary.
The bundle app remained upgradeable because we adapted its output instead of modifying vendor code. The custom theme now has one documented cart contract rather than knowledge of each installed app.
One tap produced one cart mutation, one drawer refresh and one add_to_cart event. Cart quantities matched the selected bundle on the slow mobile profile that originally exposed the race.
“Desktop version from Figma is nearly there, but the phone page looks like all the boxes are pushing each other sideways. Can you finish it properly and keep the words/photos editable for our campaign manager?”
The desktop composition was accurate because its main wrapper was exactly 1180 pixels wide and each card was exactly 373 pixels. At narrower widths, the build scaled and shifted that fixed canvas. The usual 375-pixel phone preview happened to hide the overflow; a 393-pixel device plus a longer translated heading revealed it immediately.
We first moved repeated testimonial and feature content into Webflow CMS so layout could be tested against realistic short, long and missing values. Then the fixed canvas became a fluid container, cards became a content-aware grid and type/spacing moved to clamp-based tokens. Instead of drawing another set of breakpoint exceptions, we defined what each component should do when space or content changed. The handoff included a CMS editing pass with the campaign manager, not just screenshots from our account.
Webflow · page-level custom code CSS .campaign-grid {
position: relative;
left: 50%;
width: 1180px;
transform: translateX(-50%);
}
.campaign-card { width: 373px; } The browser always reserved 1180 pixels, even when the viewport was smaller. Webflow breakpoint nudges only moved the overflow; they did not remove it.
:root { --page-gutter: clamp(1.25rem, 4vw, 4rem); }
.campaign-grid {
display: grid;
grid-template-columns:
repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: clamp(1rem, 2vw, 1.5rem);
padding-inline: var(--page-gutter);
} Cards choose a column count from available space and collapse to the viewport width before they can overflow. CMS content can grow without changing fixed coordinates.
The small custom CSS block expresses layout rules that Webflow’s visual classes could share. It replaced five isolated overrides and kept text, images and collection ordering fully editable in the designer.
Patching the reported iPhone screenshot would have failed again on translation, zoom or the next copy edit. Fixing the underlying sizing model made the page resilient to all three.
The page held its visual hierarchy across the agreed viewport set, long CMS content wrapped naturally, and the marketing team could add a new card without cloning custom positioning rules.
“The enquiries are inside HubSpot, so the form definitely sends. Google Ads reports basically nothing, Meta reports too much, and I can see two GTM-looking snippets in the source. We need numbers we can actually use.”
GTM Preview showed generate_lead as soon as the button was clicked — even when browser validation rejected the form. A second container, pasted into one Webflow template months earlier, produced duplicate page and Meta events. Google Ads looked lower because its tag respected the current consent state while the older Meta tag did not follow the same trigger path.
We removed the template-level duplicate, documented the remaining container and stopped treating a click as a business result. The form integration now returns a submission ID only after HubSpot accepts the lead. That confirmation calls one analytics function with form ID, page path and the non-personal submission ID. We tested accepted, validation-error, CRM-timeout, consented and denied scenarios in GTM Preview and GA4 DebugView, then recorded the event contract for future campaign tags.
src/analytics/lead-events.js JavaScript submitButton.addEventListener('click', () => {
dataLayer.push({
event: 'generate_lead',
email: form.email.value
});
}); A click is not a completed form, and the payload exposed an email address to every tag with container access. Validation or CRM failure did not cancel the event.
export function trackConfirmedLead({ formId, submissionId }) {
window.dataLayer.push({
event: 'lead_confirmed',
form_id: formId,
event_id: submissionId,
page_path: location.pathname
});
} The function is called only from the confirmed submission branch. Its shared event ID supports deduplication and its payload contains no name, email or phone number.
Keeping one neutral source event in the site made GTM responsible for platform-specific mapping. New ad platforms can be added without changing the form or inventing another definition of a lead.
Adding more tags would have made the mismatch worse. We first established one trustworthy business event, then allowed consent-aware platform tags to subscribe to it.
A successful HubSpot record produced one traceable conversion. Invalid and failed submissions produced none. The final handoff included a readable event map rather than an unexplained container export.
“On our laptops pricing behaves, but the live page sometimes prints a hydration warning and the yearly toggle jumps back to monthly. Mobile feels heavy too. Please keep this a repair, not another rebuild project.”
The component read localStorage while creating its initial state. On the server, localStorage does not exist, so HTML was rendered with monthly prices. A returning visitor’s browser immediately rendered annual prices from saved state. React received two different trees and warned during hydration; depending on timing, the switch then snapped back to the server value.
The earlier implementation marked the entire pricing section use client just to support one toggle. We moved plan content back to the server and isolated the interactive preference in a small BillingCycleToggle. Server and browser now begin with the same monthly structure; after hydration, an effect applies the saved preference. A returning-user test was added alongside fresh-session, direct-navigation, refresh and keyboard cases. That both fixed the error and removed unnecessary plan markup from the client bundle.
components/BillingCycleToggle.tsx TSX const saved = typeof window === 'undefined'
? 'monthly'
: localStorage.getItem('billing-cycle');
const [cycle] = useState(saved);
return <PricingGrid cycle={cycle} />; The server always chose monthly while a returning browser could choose annual during its first render. Both sides generated different prices and accessibility labels.
const [cycle, setCycle] =
useState<BillingCycle>('monthly');
useEffect(() => {
const saved = localStorage.getItem('billing-cycle');
if (saved === 'annual') setCycle('annual');
}, []);
return <CycleSwitch value={cycle} onChange={setCycle} />; The initial tree is deterministic. Preference loading happens after hydration, and the server-rendered PricingGrid no longer sits inside the client component.
Suppressing the warning or disabling SSR would conceal the mismatch while keeping the performance cost. Shrinking the client boundary corrected the lifecycle and reduced JavaScript at the same time.
The smallest safe repair was architectural, not visual: move the state to the exact component that needs it and leave stable content on the server.
Fresh and returning sessions rendered without hydration errors, the saved billing cycle applied predictably, keyboard behaviour remained intact and the pricing page shipped less client-side code.
“Our developer disappeared ages ago and wp-admin is showing around forty updates. Nobody here wants to press the button because this site pays the bills. We need it current, but checkout and Google pages cannot vanish.”
Most plugins could be updated normally once dependencies were ordered. The real risk sat in the child theme: its review-order.php override came from an old WooCommerce version and read a product’s public price property directly. The current plugin had kept a compatibility layer, but the planned update removed the behaviour that made the template appear to work.
We built a recoverable staging copy, crawled critical journeys and grouped changes by dependency rather than age. Before the WooCommerce batch, the override was reduced to the part the design actually needed and switched to the supported cart subtotal API. Updates then ran through committed WP-CLI batch scripts, each preceded by a database export and followed by the same checkout, account, search, lead and metadata smoke tests. When a minor plugin failed, set -e stopped that batch before later changes obscured the cause.
child-theme/woocommerce/checkout/review-order.php PHP $product = $cart_item['data'];
$price = $product->price * $cart_item['quantity'];
echo wc_price($price); The template read a public property that newer WooCommerce versions no longer guarantee. It also recalculated totals without tax, discount or cart rounding rules.
$product = $cart_item['data'];
$quantity = $cart_item['quantity'];
echo wp_kses_post(
WC()->cart->get_product_subtotal($product, $quantity)
); WooCommerce now remains the owner of subtotal calculation and formatting. The child theme only renders the supported result and escapes the permitted markup.
Updating this override before the plugin batch removed the known incompatibility while the old environment was still recoverable. The change was smaller and safer than replacing the checkout template wholesale.
Forty updates were not treated as one irreversible event. Small batches created checkpoints and made every regression attributable to a short list of changes.
The supported update path completed without a blind production jump, checkout totals continued to follow WooCommerce rules and remaining technical debt became a prioritised queue with owners and risk notes.
“Three people swear they filled the launch form, the page thanked them, but there is no trace in HubSpot. I think Framer calls a webhook through something old. Also please don’t create duplicates when Wi-Fi is slow.”
The Framer override started a request to a legacy relay and immediately set the form to sent. It did not await the promise or inspect the response. The relay itself could return 200 after queueing work even when HubSpot later answered 429. Visitors saw success in every one of those paths, and a manual retry could create two contacts if the first queue eventually recovered.
We kept the visible component but replaced its submission boundary. The browser sends a generated idempotency key to a small server endpoint, which validates an allow-list of fields and calls HubSpot. A delivery result is stored against that key. If a slow connection repeats the request, the endpoint returns the original result rather than creating another contact. Framer shows success only after a confirmed response; temporary failure keeps the entered values and presents a retry action. Logs retain the delivery ID and status, not the visitor’s full message.
framer/LeadFormOverride.tsx TypeScript fetch(LEGACY_WEBHOOK, {
method: 'POST',
body: JSON.stringify(values)
});
setStatus('sent'); The request was fire-and-forget. A network failure, rate limit or invalid CRM response could not stop the success message.
const submissionKey = useRef(crypto.randomUUID());
const response = await fetch('/api/leads', {
method: 'POST',
headers: { 'Idempotency-Key': submissionKey.current },
body: JSON.stringify(values)
});
setStatus(response.ok ? 'sent' : 'retry'); The interface waits for the controlled endpoint. The unique key makes repeating the same submission safe, while an unsuccessful response preserves a recoverable UI state.
HubSpot credentials, validation rules and retry records belong on the server, not in a Framer override. The component now owns presentation; the endpoint owns delivery.
Replacing the whole form or adding another automation service would create more handoff points. One thin server boundary removed ambiguity without changing the campaign page.
The page acknowledged only CRM-confirmed submissions, repeated requests returned the first delivery result and support could investigate failures from a delivery ID without exposing complete lead content.
“We moved the new site live on Friday and now old Google links plus two ad campaigns land on 404. There is a spreadsheet from the last agency with a couple hundred URLs. Can you rescue this without redirecting every missing page to the homepage?”
The sheet mixed live paths, UTM-decorated duplicates, trailing-slash variants and German/English pages. Several rows pointed to another old URL rather than a final destination. A catch-all rule attempted to make the report look clean by sending every unknown path to the homepage, including misspellings and genuinely removed content.
We combined the sheet with a crawl, indexed results and analytics landing pages, then assigned a disposition to each path: exact destination, pattern destination, intentionally gone or unresolved for content review. Redirect chains were flattened to one hop and locale stayed part of the matching key. The small edge handler normalises only the trailing slash, looks up an explicit destination and otherwise allows Webflow to return a truthful 404. After release, a second crawl checked status, final URL, canonical and campaign query preservation.
edge/redirects.ts TypeScript const route = findPage(url.pathname);
if (!route) {
return Response.redirect(new URL('/', url), 301);
} Every missing URL became a permanent homepage redirect. Search engines and visitors received no signal about the content they originally requested.
const source = url.pathname.replace(/\/+$/, '') || '/';
const target = redirectMap.get(source);
if (target) {
const destination = new URL(target, url);
destination.search = url.search;
return Response.redirect(destination, 301);
}
return fetch(request); Only reviewed sources redirect. An unmatched path reaches the site normally and can return a real 404, while query parameters remain attached to the destination URL.
Explicit intent mapping is more work than a catch-all, but it prevents soft 404s and makes the migration auditable. The map is a maintainable content artifact; the handler stays deliberately boring.
A homepage redirect technically removes an error code but answers the wrong question. Matching content intent preserves context for both campaign visitors and search engines.
Priority search and campaign URLs reached relevant live pages in one hop, locale mappings stayed consistent, redirect chains disappeared and invalid paths once again returned an honest 404.
“We run workshops in three cities. The schedule lives in our booking software, the public site is WordPress and deposits go through WooCommerce. We need visitors to see real spaces, choose extras and pay the deposit without staff copying sessions by hand. Existing bookings must stay where they are. Big concern: the booking API occasionally takes forever, so the website cannot turn into a blank spinner or accidentally sell the last seat twice. Admin also needs a manual refresh and some way to see when the data was last synced.”
The initial prototype called the booking API directly from a WordPress shortcode every time a visitor changed a date. It looked acceptable with one developer and one location. Under a realistic response delay, each filter change started another PHP request and occupied a web worker until the provider answered. A timeout returned an empty schedule, which the interface incorrectly presented as “No sessions available.” The API also returned UTC timestamps while each venue sold according to its local day; an evening workshop in one city appeared under tomorrow’s date on the website.
Before building checkout, we wrote a field map for venue, resource, session, capacity, optional equipment and deposit rules. Provider IDs were stored as stable identifiers; titles were display content only. A background queue imported the next booking window into dedicated plugin tables and normalised every timestamp with the venue’s IANA time zone. The public schedule read that local snapshot, showing its last successful sync time. When the provider slowed down, visitors saw slightly stale but labelled availability instead of a false empty state, while a queued refresh retried with backoff.
Checkout added a short capacity hold rather than creating the remote booking immediately. The hold ID travelled in WooCommerce order metadata. After payment, a signed job converted the hold into the provider reservation and saved the remote confirmation against the order. Provider webhooks invalidated affected sessions, but webhook delivery was not trusted as the only mechanism: a reconciliation job compared paid orders, local holds and remote bookings. We added an admin screen for failed syncs, manual retry and an audit trail so staff did not need server access to understand a discrepancy.
The public page never waits on the provider. Only the hold and confirmation stages can change capacity, and every transition has a persisted identifier that support can trace.
wp_workshop_slots Normalized availability snapshots wp_workshop_holds Expiring capacity reservations RefreshAvailability Retryable background sync job Sync health screen Manual retry and audit trail plugins/workshop-booking/src/AvailabilityService.php PHP function load_slots(string $location): array {
$response = wp_remote_get(
BOOKING_API . '/slots?location=' . $location
);
return json_decode(
wp_remote_retrieve_body($response), true
);
} Every visitor request waited for the provider. There was no timeout branch, cache age, timezone normalisation or distinction between “sold out” and “API unavailable.”
$key = SlotKey::from($venueId, $localDate);
$snapshot = $store->find($key);
if (!$snapshot || $snapshot->needsRefresh()) {
$queue->dispatch(new RefreshAvailability($key));
}
return new AvailabilityView(
slots: $snapshot?->sellableSlots() ?? [],
syncedAt: $snapshot?->syncedAt,
degraded: $snapshot?->isStale() ?? true
); The page reads a versioned local snapshot and schedules refresh work separately. It can state that data is stale without pretending the schedule is empty, and venue-local dates are already normalised before rendering.
We deliberately separated browsing availability from reserving capacity. Cached reads protect the website from provider latency; a short server-side hold plus post-payment reconciliation protects the final seat from being sold twice.
plugins/workshop-booking/src/CapacityHoldService.php PHP $available = $api->availableSeats($sessionId);
if ($available >= $quantity) {
WC()->cart->add_to_cart($depositProduct, $quantity);
} Two visitors could read the same remaining seat count before either cart changed anything. Both passed the check, so the website could accept two deposits for one place.
$db->transaction(function () use ($sessionId, $quantity) {
$slot = $slots->lockForUpdate($sessionId);
if ($slot->remaining() < $quantity) {
throw new CapacityUnavailable();
}
$hold = $holds->create($slot, $quantity, expiresIn: 600);
WC()->session->set('capacity_hold_id', $hold->id);
}); A row lock makes reading capacity and creating the hold one atomic operation. The ten-minute expiry releases abandoned carts without waiting for staff cleanup.
The cart itself is not a reservation system. Persisting a short hold gave checkout a concrete capacity claim that could be confirmed after payment or released on expiry.
plugins/workshop-booking/src/WebhookController.php PHP $payload = json_decode(file_get_contents('php://input'), true);
$sync->replaceSession($payload['session']);
return new WP_REST_Response(['ok' => true]); Anybody could post a session-shaped payload, and the provider request performed database work synchronously. A duplicate webhook repeated the same mutation.
$raw = $request->get_body();
$signature = $request->get_header('X-Booking-Signature');
if (!$signer->verify($raw, $signature)) {
return new WP_Error('invalid_signature', status: 401);
}
$event = ProviderEvent::fromJson($raw);
$inbox->storeOnce($event->id, $event);
$queue->dispatch(new ReconcileSession($event->sessionId));
return new WP_REST_Response(null, 202); The request is authenticated, the provider event ID makes delivery idempotent and the expensive reconciliation moves to the retryable queue.
Webhooks became hints to reconcile state, not commands trusted to overwrite it. That distinction covered duplicate, delayed and out-of-order provider delivery.
Making WordPress the new booking database would duplicate operational ownership and make staff use two systems. The plugin instead acts as a resilient adapter: the booking platform owns sessions, WooCommerce owns payment and the integration records how the two relate.
Visitors could filter venue-local sessions, choose extras and pay a deposit without waiting on a live third-party request. Sold-out capacity, failed payment, expired hold, delayed webhook and provider-timeout paths were all represented explicitly, while staff received a visible reconciliation queue instead of silent data drift.
“We want to move from Lightspeed to Shopify, but it is not just products. There are years of customer accounts, variants where the same SKU was reused, gift cards, subscription customers, wholesale price lists and stock coming from our warehouse system. The old store must keep taking orders while the new theme is being built. We can accept a short checkout freeze on launch night, not several days. We also sell in the UK and EU, so prices, tax display and old localized URLs need to land correctly. Previous test import made duplicate products when it was restarted, so we need something we can rehearse more than once without cleaning Shopify manually each time.”
The first proof-of-concept treated product title and SKU as identity. Neither was stable: titles had changed over the years and several wholesale packs intentionally reused the retail SKU. When the script hit a Shopify rate limit and restarted, it could not tell which rows were already committed. It created another product, applied inventory to both and left the test store looking successful only at the total-count level.
We created a migration ledger keyed by immutable Lightspeed entity IDs. Every Shopify product, variant, customer and order received a source-system metafield, and every successful write recorded the source ID, Shopify ID, payload checksum and cursor position. The importer became idempotent: rerunning the same row updated the linked record only when its checksum changed. Writes were grouped around Shopify’s cost-based limits, and a dead-letter queue held records needing human mapping rather than allowing one malformed variant to stop the whole catalogue.
Data moved in rehearsed layers. The base import established catalogue, customers and historical orders in a development store. A custom Shopify app then handled ongoing warehouse inventory, translating location IDs and ignoring its own webhook echoes. Subscription records were mapped only after product and customer identities were stable; payment credentials were handled through the supported provider migration path rather than placed in export files. Wholesale rules became explicit company/catalog assignments, not tags interpreted by theme JavaScript.
For launch, we ran a delta import, compared entity and inventory reconciliation reports, then placed only the old checkout into a short maintenance window. Orders created since the rehearsal were imported from the saved cursor, DNS and payment checks were completed, and the warehouse integration changed its destination after Shopify inventory matched the signed-off report. The rollback plan kept the old storefront read-only and preserved the final source cursor, so returning did not mean guessing which system held the latest order.
Every stage consumes stable source IDs and produces a checkpoint. A failed run resumes from evidence stored in the ledger instead of inferring progress from Shopify product counts.
migration_links Source-to-Shopify identity ledger dead_letter_items Records needing manual mapping reconcile-inventory.ts Location-level quantity report cutover-runbook.md Timed launch and rollback steps migration/import-products.ts TypeScript for (const item of sourceProducts) {
const existing = await shopify.findByTitle(item.title);
if (existing) await shopify.update(existing.id, item);
else await shopify.create(item);
} Titles were not unique or permanent, and the loop kept no durable checkpoint. A rate-limit restart could create duplicates or update the wrong product with a matching name.
for await (const row of source.fromCursor(checkpoint)) {
const sourceKey = 'lightspeed:product:' + row.id;
const link = await ledger.find(sourceKey);
const checksum = hash(normalizeProduct(row));
const product = await shopify.upsert({
id: link?.shopifyId,
sourceKey,
input: normalizeProduct(row)
});
await ledger.commit(sourceKey, product.id, checksum, row.cursor);
} Immutable source identity, an upsert target and a committed cursor make the batch resumable. Reprocessing a row converges on the same Shopify record instead of creating another one.
The ledger was not temporary import plumbing; it became the audit boundary for later inventory and webhook sync. Both systems could refer to the same entity without relying on names, SKUs or execution order.
migration/schema.sql SQL CREATE TEMP TABLE imported_products (
product_title TEXT,
shopify_id TEXT
); A process restart erased progress, while product title collisions silently replaced one mapping with another. There was no cursor or payload history to audit.
CREATE TABLE migration_links (
source_system TEXT NOT NULL,
entity_type TEXT NOT NULL,
source_id TEXT NOT NULL,
shopify_id TEXT NOT NULL,
payload_hash TEXT NOT NULL,
source_cursor TEXT NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (source_system, entity_type, source_id)
); Immutable source identity is unique at the database boundary. The Shopify ID, checksum and cursor survive restarts and make each write traceable.
A durable uniqueness constraint is stronger than application discipline. Even two concurrent workers cannot create two ledger links for the same Lightspeed entity.
app/webhooks/inventory-levels-update.ts TypeScript export async function onInventoryUpdate(event) {
await warehouse.setQuantity(event.sku, event.available);
await shopify.setQuantity(event.sku, event.available);
} A Shopify change wrote to the warehouse, which emitted another change back to Shopify. Reused SKUs also made the destination location ambiguous.
export async function onInventoryUpdate(event: InventoryEvent) {
if (await inbox.seen(event.webhookId)) return;
if (event.origin === APP_ORIGIN) return;
const link = await locations.find(event.shopifyLocationId);
await warehouse.setQuantity({
warehouseId: link.warehouseId,
sourceVariantId: event.sourceVariantId,
available: event.available
});
await inbox.commit(event.webhookId);
} Webhook ID deduplicates delivery, origin prevents an echo loop and the location/variant ledger replaces unsafe SKU-only addressing.
Inventory sync needed identity in two dimensions: which variant and which physical location. The same migration ledger pattern was extended instead of inventing a second mapping system.
A one-shot CSV import could move visible catalogue fields but not establish durable identity across subscriptions, inventory, wholesale rules and future webhooks. Rehearsable, idempotent stages reduced cutover risk and produced evidence for every exception.
The migration could be run repeatedly against a clean or partially populated Shopify store without multiplying records. The final cutover used a saved delta cursor, inventory and entity reconciliation reports, explicit unresolved-record queues and a rollback point tied to the last accepted source order.
“We currently have a PDF and a giant pricing spreadsheet that sales uses on calls. We want a calculator on the Webflow site where a visitor chooses team size, regions, onboarding, support and a few optional modules, then sees an estimated range. They should be able to email the configuration to themselves or send a link to a colleague. Sales needs the same choices inside HubSpot, but we cannot put customer email or company name in the shared URL. The spreadsheet has minimum fees, volume bands and two combinations that are not allowed. Marketing must still be able to edit all explanatory copy in Webflow without asking engineering. Estimate is not a binding order, and nobody should be able to change a number in browser dev tools and submit a fake quote.”
The spreadsheet was not copied directly into code. We first turned its rows into named business rules and asked sales to resolve contradictions: one tab applied the volume discount before the regional minimum, another applied it after. Approved examples became fixtures with expected totals. Marketing copy, help text and module descriptions remained Webflow CMS content; the embedded application received only stable option IDs from data attributes.
The early browser prototype incremented a running total whenever a checkbox changed. Going Back and Next replayed change handlers and added the same onboarding option again. It also trusted prices stored in HTML, which meant a visitor could edit data-price and submit any total. We replaced the accumulator with a pure quote engine: every screen derives a fresh result from the complete normalized configuration. The browser displays an estimate, but the server validates the same option IDs, recalculates from its own catalog version and signs the resulting quote.
Share links contain only versioned configuration — plan, quantity band, regions and module IDs. Contact fields never enter the URL. Opening a link restores the steps and asks for contact information only when the visitor requests the PDF or sales follow-up. HubSpot receives both human-readable choices and the signed quote ID, so a rep can reopen the exact server-calculated version even after marketing updates website copy.
We added keyboard navigation, inline rule explanations and a summary that announces price changes without moving focus. Analytics records step name, selected option IDs and completion state, but no contact values. The release included spreadsheet fixtures, API contract tests, incompatible-option tests and a “catalog version expired” path that recalculates an old shared link instead of silently showing stale numbers.
The same normalized configuration drives the UI summary, server price and CRM payload. Only non-personal option IDs are serialised into share links.
pricing-catalog.v3.json Versioned plans and option IDs sales-fixtures.test.ts Approved spreadsheet examples quote-v1.schema.ts Browser/API contract hubspot-property-map.ts Stable CRM field mapping quote/QuoteEngine.ts TypeScript let total = Number(basePlan.dataset.price);
form.addEventListener('change', (event) => {
const option = event.target as HTMLInputElement;
total += option.checked
? Number(option.dataset.price)
: -Number(option.dataset.price);
}); The total depended on interaction history, not current state. Replayed events double-counted options, and prices exposed in HTML became client-controlled input.
export function priceQuote(
config: QuoteConfig,
catalog: PricingCatalog
): QuoteResult {
const plan = catalog.plan(config.planId);
const modules = config.moduleIds.map(id => catalog.module(id));
const subtotal = Money.sum(plan.feeFor(config.seats), modules);
return applyPolicies(subtotal, config, catalog.policies);
} The function is deterministic: the same configuration and catalog version always produce the same result, regardless of how the visitor moved through the steps.
Pricing became a domain function rather than UI state. That made spreadsheet examples testable and allowed the browser, PDF generator and CRM integration to share one definition of the quote.
api/quotes/create.ts TypeScript const request = await req.json();
await hubspot.createDeal({
amount: request.total,
configuration: request.configuration
}); The endpoint trusted a browser-supplied total and arbitrary option object. A modified request could create a CRM deal with an impossible configuration or invented amount.
const input = quoteRequestSchema.parse(await req.json());
const catalog = await catalogs.load(input.catalogVersion);
const quote = priceQuote(input.configuration, catalog);
const saved = await quotes.create({
configuration: input.configuration,
catalogVersion: catalog.version,
total: quote.total,
signature: signer.sign(quote)
});
return json(publicQuote(saved), 201); The server accepts known IDs only, loads its own catalog, recalculates the total and stores a signed immutable quote before CRM or PDF work begins.
The website estimate can remain responsive without becoming authoritative. Sales and document generation use the signed server record, not values posted from the browser.
quote/share-state.ts TypeScript const share = btoa(JSON.stringify({
...formValues,
email,
company,
total
}));
history.replaceState(null, '', '?quote=' + share); Base64 is not encryption. Contact data and the editable total leaked into browser history, analytics referrers, screenshots and copied links.
export function toShareParams(config: QuoteConfig) {
return new URLSearchParams({
v: config.catalogVersion,
p: config.planId,
s: String(config.seats),
r: config.regionIds.join(','),
m: config.moduleIds.join(',')
});
} Only allow-listed product choices are shareable. Personal fields and totals are deliberately absent, and the catalog version makes old links recoverable.
The shared URL is a configuration bookmark, not a quote document. Opening it always requests a fresh server calculation before a visitor can submit or export anything.
A third-party calculator embed would have been quick, but it could not express the pricing policies, keep Webflow content ownership or give sales a signed versioned record. A small embedded application provided those boundaries without rebuilding the marketing site.
Visitors could explore valid combinations, understand why a rule changed the estimate, share a privacy-safe configuration and request a server-verified quote. Sales received structured HubSpot fields and an exact quote ID rather than a screenshot or free-text form message.
“Support handles every return by email right now. We want logged-in customers to open an order, choose specific items, pick a reason, add photos if damaged and see whether it is refund, exchange or store credit. Final-sale products, used hygiene items and orders outside the country-specific window must be blocked with a clear explanation. Expensive items need staff approval before a shipping label is issued; ordinary returns can go straight to our 3PL. Customers should see status in their account and receive emails when the warehouse scans the parcel. Please do not make support copy order numbers between Shopify, the returns spreadsheet and the warehouse portal anymore.”
The first mock-up checked only order age and a final-sale tag. That failed as soon as one order contained both eligible and ineligible lines, or when the delivery date differed from the order date. Policy also varied by market: one region counted calendar days from delivery, another allowed an extended holiday window, and damaged items required evidence even when otherwise ineligible for a normal return.
We modelled eligibility as a pure decision per line item. Inputs include delivery timestamp, market, product policy class, quantity already returned, requested resolution and reason. The output is not just true or false; it contains allowed quantity, permitted resolutions, approval requirement and a customer-safe explanation code. That same result controls the account UI and is recalculated by the server when a request is submitted, so hiding a disabled button cannot bypass policy.
Photo evidence uses short-lived signed upload URLs scoped to the authenticated customer, order and return draft. Files are quarantined, type-checked from content rather than filename, scanned and attached to the draft only after verification. The browser never receives warehouse credentials and support never downloads attachments from email.
Submitting an eligible draft creates an RMA state machine. Low-risk returns can request a label from the 3PL; high-value or exception cases enter needs_review. Warehouse webhooks are signature-checked and stored by event ID before processing. Only allowed transitions — requested to in_transit to received to inspected to refunded — can run side effects. Duplicate received events therefore cannot issue two refunds. Customers and support read the same timeline, with internal notes kept separate from customer-visible messages.
Every external event enters through an authenticated inbox and every financial action belongs to an explicit state transition. The theme only renders the workflow; it does not own policy.
return-policy.v2.ts Market and item eligibility rules rma-transitions.ts Allowed workflow state changes webhook_inbox Idempotent 3PL event storage returns-timeline.tsx Shared customer/support history returns/evaluate-line.ts TypeScript export function canReturn(order, product) {
const age = differenceInDays(new Date(), order.createdAt);
return age <= 30 && !product.tags.includes('final-sale');
} The rule used order date instead of delivery, ignored market and prior returns, and gave one boolean for an entire item regardless of reason or requested resolution.
export function evaluateLine(input: ReturnLineInput): Decision {
const policy = policies.forMarket(input.market);
const deadline = policy.deadlineFrom(input.deliveredAt);
const remaining = input.fulfilledQty - input.previouslyReturnedQty;
return policy.decide({
class: input.productPolicyClass,
reason: input.reason,
resolution: input.resolution,
requestedQty: Math.min(input.requestedQty, remaining),
deadline
});
} Eligibility is calculated per fulfilled line from explicit policy inputs. The result can allow an exception, require approval or explain a rejection without duplicating rules in the UI.
A pure decision function can be exercised against a policy matrix without Shopify, a browser or the 3PL. That made legal/policy review possible before workflow side effects were connected.
returns/uploads/create-ticket.ts TypeScript const file = await request.formData();
await storage.put(file.name, file);
return json({ url: storage.publicUrl(file.name) }); The server trusted filename and browser MIME type, accepted an unbounded upload and returned a public URL unrelated to the authenticated order.
const input = uploadTicketSchema.parse(await request.json());
await orders.assertOwnedBy(input.orderId, session.customerId);
const ticket = await uploads.issue({
ownerId: session.customerId,
returnDraftId: input.returnDraftId,
maxBytes: 8_000_000,
allowedTypes: ['image/jpeg', 'image/png'],
expiresInSeconds: 300
});
return json(ticket); The signed ticket is short-lived, size/type constrained and bound to the customer’s return draft. Verification and malware scanning happen before attachment.
Direct-to-storage upload kept large files away from the application server, while the ticket preserved authorization and gave the scanner a quarantine boundary.
returns/webhooks/three-pl.ts TypeScript if (event.status === 'received') {
await shopify.refund(event.orderId);
await returns.update(event.rmaId, 'refunded');
} Repeated or out-of-order warehouse events could trigger the refund again. The code skipped inspection and moved directly from an external string to a financial side effect.
const event = await inbox.storeOnce(request);
if (!event) return accepted();
await rmas.transition(event.rmaId, event.type, async state => {
if (!state.canApply(event.type)) return state.ignore(event.id);
if (event.type === 'parcel.received') return state.to('received');
if (event.type === 'inspection.approved') {
const refund = await refunds.createOnce(state.refundKey());
return state.to('refunded', { refundId: refund.id });
}
return state;
}); Webhook ID deduplicates delivery, the state machine rejects invalid order and the refund itself has an idempotency key derived from the RMA.
External logistics status and internal financial status are related but not interchangeable. Explicit transitions ensured warehouse retries could never become duplicate money movement.
Installing a generic returns app would still require policy exceptions, 3PL mapping and separate support notes. Building the policy and state boundaries explicitly let the storefront remain branded while operational rules stayed testable and auditable.
Customers could request eligible item quantities, upload evidence, receive the correct approval path and follow one status timeline. Support stopped retyping order data, warehouse retries became safe and refund creation occurred only after the recorded inspection transition.
What we maintain
Got a WordPress store running on 40 plugins nobody's dared touch in two years? That's a normal Tuesday for us.
If your CMS can't do something out of the box, we write the code that makes it do it anyway.
Engineering quality
“Done” does not mean “it worked on our machine.” It means the change is understandable, fits your stack, passes the relevant checks and arrives with clear evidence of what was shipped.
We follow the project’s conventions, reuse proven patterns and avoid unnecessary rewrites, packages or architecture changes.
Focused diffs, clear names and no dead code, debug leftovers or hidden workarounds. Every change should be understandable later.
We run the project’s existing build, lint and tests, then verify the affected pages, states and critical user flows.
The work stays in your codebase with a changelog, test notes and any rollout or rollback instructions another developer may need.
If we find fragile legacy code or a larger architectural problem, we explain the risk and options before expanding the task. No surprise rebuilds and no technical lock-in.
See what every task includesWho we're for
Marketing, product, e-commerce, and agency teams use TUNDRÄ when recurring web work needs a reliable owner, a visible queue, and a safe path to production.
Who keep finding broken forms, outdated pricing pages, and half-shipped landing pages, with no developer to hand them to.
Whose site was built by an agency that's since gone quiet, or a freelancer who's moved on.
That need constant small changes: new collections, banner swaps, checkout tweaks, but can't justify a full-time hire for that.
That need overflow development capacity for client work, without adding headcount.
Compare your options
Every option can produce good work. The difference is what it asks from you: time, headcount, coordination, or one predictable queue.
| What matters | Best for ongoing work TUNDRÄ Managed queue | DIY tools Run it yourself | Full-time hire Add headcount | Freelancer Book as needed | Traditional agency Commission a project |
|---|---|---|---|---|---|
| Cost & commitment | $1,450/mo to start. Cancel anytime. | Lowest software cost; your time carries the delivery cost. | Salary, benefits, recruiting, onboarding, and unused capacity. | Hourly or per project; often lowest for one isolated task. | Project quote or retainer, usually with account overhead. |
| Starting the next task | Add it to the same queue. First response within 4 hours on Maintain or 2 hours on Grow. | Start whenever you have time to direct and check it. | Immediate when the person has capacity. | Check availability, then agree a new brief and estimate. | Often requires scoping, a proposal, and scheduling. |
| What you manage | Share the problem; review previews and key decisions. | Briefing, access, technical choices, testing, and release. | You set priorities, provide direction, and review the work. | You scope, brief, follow up, and check delivery. | You coordinate through calls, milestones, and approvals. |
| Project continuity | One workspace for task history, decisions, changelogs, and recovery notes. | You maintain the instructions, project context, and records. | Deep internal context, concentrated in one employee. | Context often lives with one person and may need rebuilding. | Strong project process; small requests may need a new scope. |
| Best fit | A recurring website backlog without another full-time hire. | Technical owners who want to run delivery themselves. | Enough consistent work for a dedicated role. | Occasional, clearly scoped work. | Large redesigns, strategy, or complex transformation. |
Your best choice depends on the work. TUNDRÄ is designed for the gap between occasional help and another full-time hire: a backlog that keeps returning and needs to keep moving.
Security & production safety
We treat access to your website as a temporary responsibility, not a blank cheque. Your credentials are used only to deliver authorised work — never sold, reused or passed to unrelated third parties.
We ask only for the permissions a task needs. Whenever the platform allows it, we use a named, limited or time-bound account instead of your main login.
Passwords and private keys are never requested in task comments or email. Access is shared through your approved password manager or a secure platform invitation.
Risky work is staged or explicitly approved before launch. We limit the change surface, keep a recovery path and do not quietly experiment on your live website.
Before material changes we confirm a backup or rollback plan, test the affected flows and return a clear changelog so you know exactly what changed.
Our production protocol
For sensitive releases we may require staging, a fresh backup, a maintenance window or written approval. If a safe recovery path is missing, we pause and fix that first.
Every plan
Every request follows the same complete delivery workflow. There are no separate charges for QA, publishing, documentation, or handing the work back to your team.
Plans
Both plans include unlimited requests. The difference is how many tasks we work on at once and how quickly your backlog moves.
For teams that need a reliable queue across one or two projects for fixes, updates, and focused product work.
For growing teams running multiple products and shipping through a shared, always-available delivery workspace.
For agencies, e-commerce teams, and multi-site companies that need a larger queue and tighter delivery rhythm.
Questions, answered
The practical details: how work enters the queue, how releases stay recoverable, and where the service boundaries sit.
Maintain includes an initial-response SLA of up to four hours. Grow includes an initial-response SLA of up to two hours. The SLA covers acknowledgement and first triage, not guaranteed completion: delivery time still depends on complexity, access, approvals, and third-party systems.
It depends on task complexity. A month may contain many focused fixes, content changes, tracking updates, and page sections, or fewer larger milestones. You can keep any reasonable number of requests queued; Maintain moves one active task at a time and Grow moves up to two. The live proof section shows completed work and average active work time.
No. A URL, screenshot, voice note, or rough description is enough. If the site is fragile or undocumented, the task starts with diagnosis, access verification, and finding a safe change surface. You see the risk and options before the scope expands.
Corrections needed to meet the agreed task are included. A confirmed defect introduced by TUNDRÄ is handled outside your normal queue and can run in parallel, so it does not consume a workstream slot or stop the next planned task. A new direction or expanded requirement becomes a separate queue item.
Never paste them into a task or email. Your client portal has a dedicated write-only Secrets area for any key-secret pair. The value is encrypted separately from task conversations and is not returned by the client or team portal after saving. You can rename the key, replace the value, or delete the pair. Named platform invitations and least-privilege accounts are still preferred whenever available.
That is part of the release plan. Code changes are kept in version control with a clear history. Before material CMS or production work, a backup or another verified recovery point is confirmed. If a release causes a problem, the last safe version can be restored. If there is no safe recovery path, the change does not go live.
You keep your own materials and, after full payment, receive the transferable rights TUNDRÄ owns in final work created uniquely for you. Deliverables stay in your website, repository, and platform accounts with the relevant code, assets, changelog, and handoff notes. Open-source, third-party, and reusable background components keep their existing licences.
Yes. No card is required and nothing renews automatically. The test ends after three accepted tasks or seven calendar days. You decide separately whether to start a paid subscription.
Yes. Tasks can follow your repository conventions, approval process, and release workflow. Changelogs and test notes keep the handoff clear for whoever maintains the site next.
One task is one focused outcome that can be implemented and reviewed as a coherent unit, such as fixing a form or updating a page section. Larger requests are split into visible queue items, moved to a separate scope, or paused while you decide — never turned into a surprise rebuild or invoice.
Checkout, payment, lead-form, security, and live-launch failures can move ahead of ordinary queue work when impact is confirmed. Your plan response SLA still applies, but it is an initial-response commitment rather than a guarantee that every incident will be fully resolved within two or four hours.
The subscription covers eligible development and design work. Hosting, domains, paid plugins, fonts, stock assets, platform fees, and external services remain separate unless an Order Form explicitly includes them.
You can cancel renewal at any time on the standard monthly plans. If you need a temporary billing pause, contact support; a pause takes effect only when its dates and effect on the queue are confirmed in writing.
Add your first real task now, or book a 15-minute call if you need to talk through the queue first.