Shopify Thank You Page Survey: Save Answers to Orders

A Shopify thank you page survey cannot save its own answer. The extension is read-only, and Shopify says the order “is not yet created” when the page renders. So post the answer and the order ID to your app with a session token, then write it to an order metafield from a background job that waits for the order to exist.

Everything below targets API version 2026-10, the latest stable release as of November 2026. Shopify’s own survey tutorial stops at a console.log and suggests “a database, or … metafields”. We pick up from there.

Why can’t the extension write the answer itself?

Because there is nothing writable on that page. The Thank you block target reference is direct about it: “Write access isn’t available on Thank you page targets because the order is complete.” The Order API adds that “there are no methods to modify order details from a checkout extension.”

The Attributes and Metafields APIs are both listed for the target, but here they only read.

The Storage API “persists across page loads within the current checkout session” and is scoped to your extension. Good for an “already answered” flag, useless for data the merchant reports on.

The harder constraint is timing. Shopify’s Thank you and Order status overview says: “for extensions on the Thank you page (target purchase.thank-you), the order is not yet created when these extensions are displayed. However, the order id is available.” Use that ID “to fetch additional information about the order using the GraphQL API after order creation is complete.” Shopify does not say how long that takes; a staff reply on the developer forum calls processing delays rare but possible. So the backend has to cope with arriving early.

What ID does orderConfirmation actually give you?

shopify.orderConfirmation.value carries order.id, an optional number (a random confirmation code, not the #1001 name) and isFirstOrder. The docs describe order.id as “a globally unique GID that corresponds to the order in the GraphQL Admin API”, and the type comment in @shopify/ui-extensions 2026.10.2 says it “becomes the Order object ID … after the order is created”, with gid://shopify/Order/123 as its example.

Developers logging the live value saw otherwise. Two forum threads, one on community.shopify.com in June 2024 and the April 2025 thread above, both printed gid://shopify/OrderIdentity/<number>, and the 2024 poster reported that the same number worked as gid://shopify/Order/<number> in an Admin query. That mapping comes from a community report, not Shopify’s documentation, and we have not observed a live value ourselves. So the backend below accepts either prefix and rebuilds an Order GID from the number. If the docs are right, the conversion is a no-op; if the forum is right, it is what makes the lookup work.

The extension: send the answer with a session token

Calling your own backend needs the network_access capability, which you request in the Partner Dashboard before the extension configuration can use it:

api_version = "2026-10"

[[extensions]]
type = "ui_extension"
name = "Attribution survey"
handle = "attribution-survey"

  [[extensions.targeting]]
  target = "purchase.thank-you.block.render"
  module = "./src/ThankYouSurvey.tsx"

[extensions.capabilities]
network_access = true

The component posts the answer with a fresh token from shopify.sessionToken.get(), which the docs say returns cached tokens when it can, so call it per request:

import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useState} from 'preact/hooks';

const ENDPOINT = 'https://survey.example.com/api/attribution';

export default function extension() {
  render(<Survey />, document.body);
}

function Survey() {
  const [answer, setAnswer] = useState('');
  const [status, setStatus] = useState('idle');
  const orderId = shopify.orderConfirmation.value?.order?.id;

  if (!orderId) return null;
  if (status === 'sent') {
    return <s-banner tone="success">Thanks, that helps us a lot.</s-banner>;
  }

  async function submit() {
    setStatus('sending');
    try {
      const token = await shopify.sessionToken.get();
      const response = await fetch(ENDPOINT, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({orderId, answer}),
      });
      setStatus(response.ok ? 'sent' : 'failed');
    } catch {
      setStatus('failed');
    }
  }

  return (
    <s-stack gap="base">
      <s-choice-list
        label="How did you hear about us?"
        name="attribution"
        onChange={(event) => {
          const list = event.currentTarget as HTMLElementTagNameMap['s-choice-list'];
          setAnswer(list.values?.[0] ?? '');
        }}
      >
        <s-choice value="search">Search engine</s-choice>
        <s-choice value="social">Social media</s-choice>
        <s-choice value="friend">A friend</s-choice>
        <s-choice value="other">Somewhere else</s-choice>
      </s-choice-list>
      {status === 'failed' && (
        <s-banner tone="critical">That did not send. Try again?</s-banner>
      )}
      <s-button disabled={!answer} loading={status === 'sending'} onClick={submit}>
        Send answer
      </s-button>
    </s-stack>
  );
}

The cast in onChange is there because the JSX prop is typed as a plain Event, and tsc rejects values on EventTarget without it. Our checkout extension fetch guide covers the CORS preflight that the Authorization header triggers, and the 401s that surface in the browser as an opaque TypeError.

Saving the thank you page survey answer to an order metafield

The endpoint verifies the token, validates the payload and queues a job. It skips the Admin API during the request, because the order may not exist yet and the buyer should not wait on retries.

import {jwtVerify} from 'jose';

const SECRET = new TextEncoder().encode(process.env.SHOPIFY_API_SECRET);
const ANSWERS = new Set(['search', 'social', 'friend', 'other']);
const CORS = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'Authorization, Content-Type',
  'Access-Control-Allow-Methods': 'POST',
};

export function toAdminOrderGid(id) {
  const match = /^gid:\/\/shopify\/(?:Order|OrderIdentity)\/(\d+)$/.exec(String(id));
  if (!match) throw new Error(`Unrecognised order id: ${id}`);
  return `gid://shopify/Order/${match[1]}`;
}

export async function handleSurvey(request, {enqueue}) {
  if (request.method === 'OPTIONS') return new Response(null, {status: 204, headers: CORS});

  let claims;
  try {
    const token = (request.headers.get('Authorization') ?? '').replace(/^Bearer /, '');
    ({payload: claims} = await jwtVerify(token, SECRET, {
      algorithms: ['HS256'],
      audience: process.env.SHOPIFY_API_KEY,
    }));
  } catch {
    return new Response('Unauthorized', {status: 401, headers: CORS});
  }

  let job;
  try {
    const {orderId, answer} = await request.json();
    if (!ANSWERS.has(answer)) throw new Error('bad answer');
    job = {shop: claims.dest, orderGid: toAdminOrderGid(orderId), answer, attempt: 0};
  } catch {
    return new Response('Bad request', {status: 400, headers: CORS});
  }

  await enqueue(job, {delaySeconds: 0});
  return new Response(null, {status: 202, headers: CORS});
}

The Session Token API gives you dest (the shop’s myshopify domain) and aud (your client ID) inside an HS256 token with a five-minute TTL. Check dest against the shops you hold offline tokens for before the job uses it.

The same page warns: “Your app server can trust only the claims within the session token. It can’t use the token to trust the entire HTTP request.” The orderId in the body is not one of those claims. Anyone holding a valid token for that shop can post an answer against any order ID they can guess, and order IDs are plain numbers. The token carries a sub only for logged-in buyers, so a guest cannot be tied to their order this way. For attribution data we accept that risk and narrow it with a first-answer-wins rule. For anything that moves money or stock, do not trust the body.

The job looks the order up, retries on a schedule while order comes back null, and writes once:

const LOOKUP = `#graphql
  query SurveyTarget($id: ID!) {
    order(id: $id) {
      id
      metafield(key: "attribution") { id }
    }
  }
`;

const SAVE = `#graphql
  mutation SaveAttribution($metafields: [MetafieldsSetInput!]!) {
    metafieldsSet(metafields: $metafields) {
      metafields { id }
      userErrors { field message code }
    }
  }
`;

const RETRY_DELAYS = [30, 120, 600, 3600]; // seconds

export async function writeAnswer(job, {admin, enqueue}) {
  const lookup = await admin(job.shop, LOOKUP, {id: job.orderGid});
  const order = lookup.data.order;

  if (!order) {
    const delaySeconds = RETRY_DELAYS[job.attempt];
    if (delaySeconds === undefined) throw new Error(`${job.orderGid} never appeared`);
    await enqueue({...job, attempt: job.attempt + 1}, {delaySeconds});
    return 'retrying';
  }

  if (order.metafield) return 'already-answered';

  const result = await admin(job.shop, SAVE, {
    metafields: [{
      ownerId: job.orderGid,
      key: 'attribution',
      type: 'single_line_text_field',
      value: job.answer,
      compareDigest: null,
    }],
  });
  const {userErrors} = result.data.metafieldsSet;
  if (userErrors.length) throw new Error(JSON.stringify(userErrors));
  return 'written';
}

Leaving out namespace puts the metafield in your app-reserved namespace, per the MetafieldsSetInput reference. The metafieldsSet reference says to send compareDigest: null when the metafield doesn’t exist yet and you want the write guarded, and that a digest mismatch makes the mutation return an error. That guard covers the gap between the lookup and the write if two answers race. We have not seen it trip, or which error code it uses, so the job throws on any userErrors rather than matching one.

The retry delays are our choice, not a Shopify figure: four retries spread over roughly 72 minutes. Shopify publishes no upper bound, so the job gives up loudly rather than retrying forever. If you would rather react than poll, an orders/create webhook subscription can trigger the write instead, with the duplicate-delivery handling from our webhook retries write-up.

Permissions decide whether this ships. metafieldsSet needs the same access as mutating the owner, so write_orders, and the lookup needs read_orders. Shopify’s protected customer data rules list metafields “that relate to a single customer or order” as protected, so a public app needs level 1 access approved before it can do this on a merchant’s store. Development stores only need the data selected in the Partner Dashboard. A custom app has level 1 automatically. If the merchant should see the answer on the order page, give the metafield a definition, as in our TOML metafield definitions post.

Should you ask in checkout instead?

Sometimes. A purchase.checkout.block.render extension can call applyAttributeChange() and the answer travels with the cart onto the order. The Attributes API names the catch: it “returns an error if … the buyer is using an accelerated checkout method, such as Apple Pay or Google Pay.” So the answers of those buyers cannot be saved that way, and everyone else meets the question before paying, when an extra step costs most.

What we ran, and what we did not

We type-checked the extension with TypeScript 5.9.3 against @shopify/ui-extensions 2026.10.2 and Preact 10.29.8, using a CLI-style shopify.d.ts for purchase.thank-you.block.render. Both GraphQL operations passed Shopify’s AI Toolkit schema validator for Admin API 2026-10. The toolkit’s checkout component validator would not run (it is missing its own typescript dependency), so tsc was the only check on the JSX.

The endpoint and the job ran under node:test on Node 24.12.0 with jose 6.2.15. The nine tests cover both GID prefixes, rejected tokens (wrong audience, wrong secret, none), bad payloads, the preflight, two null lookups then a write, an existing answer being kept, giving up after the last delay, and userErrors. The Admin API in them is a fake we wrote, so they prove our control flow and say nothing about Shopify’s behaviour.

None of it ran in a real checkout or against a real store. The live order.id format, the order creation delay and the compareDigest error code rest on documentation and forum reports.

What we would build

For an attribution or satisfaction question, we would put it on the Thank you page, post it to the app, and write it to an app-owned order metafield from a queued job that tolerates a missing order. We would keep the answer in our own database too, so a job that gives up loses nothing.

We would not use this pattern for anything a buyer could exploit by guessing another order’s ID. And we would only move the question into checkout if the answer has to be on the order at creation and the merchant accepts losing answers from Apple Pay and Google Pay buyers.

Whoooop builds checkout and Thank you page extensions, and the app backends behind them, for Shopify merchants. If your post-purchase scripts never moved to extensions, our Shopify development page covers how we take that on.

Need this built properly?

Get in touch