Gift Cards are bearer cards: anyone with the code can redeem an active card at checkout when Gift Cards are enabled. Checkout does not check whether the shopper matches the recipient email or linked customer. Recipient details control delivery; they do not restrict who can redeem the card. Anyone with the code can use it. Keep the code and the View your gift card link safe. If a code is leaked, disable the card to pause redemption or cancel it to permanently revoke its remaining balance.
Gift Cards are sold as products and spent at checkout. The platform owns checkout, the Gift Card Delivery email and text message, balances, codes and every message about them. Your theme owns everything a shopper reads around them on the storefront: the form that sends a card to someone, the recipient on the cart line, the Gift Card page, the page a recipient opens from their email, and the Gift Cards list in the customer account. A theme that leaves a piece out leaves shoppers without it: a Gift Card product without the recipient form can only be bought for yourself, and a language your theme ships without the gift card strings shows those words in your default language. This guide lists every piece, the data and actions behind it, what the checks enforce and the strings to ship, with Canvas as the worked example.
What to build#
Piece |
Where |
Data and actions |
In Canvas |
|---|---|---|---|
Recipient form |
The product page, and every other section with an Add to cart form that can show a Gift Card product (quick view, featured product) |
|
|
Recipient on cart lines |
Cart page and cart drawer |
|
|
Gift Card page |
|
|
|
View link |
|
The same template, with |
|
My Gift Cards |
The account page |
|
|
Strings |
Every storefront locale file |
The |
|
To read Canvas's files, install Canvas from the Theme Store on a development store and download it with tringify theme pull --store STORE_ID --theme THEME_ID (leave out --theme to list the store's themes). The download is compiled: Canvas's recipient form is one shared snippet in its source, and it appears in each section that uses it.
Required template and what the checks enforce#
The platform serves the Gift Card page on every store, so every theme needs a template for it, as it does for product and cart pages. Give the page one section of its own and list it in theme.json:
// templates/gift_card.json
{
"page_type": "gift_card",
"sections": [
{ "type": "main-gift-card", "position": 1, "settings": {} }
]
}
// theme.json, in "surfaces"
{ "surface_key": "gift_card", "required_sections": ["main-gift-card"], "allowed_sections": [] }
Check |
Runs in |
Refuses |
|---|---|---|
Template floor |
|
A theme without |
Page sections |
Every check of a package |
A section on the page that the |
Declared data ( |
Every check of a package |
A section that reads |
Words in markup ( |
Every check of a package; a code save refuses only a finding it adds |
Visible text written into markup, such as a literal "Check balance" button. Every label, hint and button goes through |
Editor labels ( |
Every check of a package |
A setting label of your Gift Card section that is missing from |
Raw recipient phone ( |
Every check of a package, as a warning |
Nothing: it warns wherever a raw |
Formatted times ( |
Every check of a package, as a warning |
Nothing: it warns when a time that comes with a ready-to-show |
Recipient fields kept |
A code save on a store (the code editor, Store MCP |
An edit that reduces how many |
The checks do not judge the rest of this guide: they do not look for a recipient form on your product page, or for your gift card strings in each locale file. A Theme Store review does: test every piece below on a development store with a Gift Card product before you publish (Publish in the Theme Store).
Recipient form#
When product.is_gift_card is true, put the recipient controls inside the product's {% form 'cart_add' %}, before the Add to cart and Buy now buttons, so both send them. A Buy now button that submits the same form with purchase_intent=buy_now keeps the fields. Bind each field's maxlength (and the message counter) to forms.cart_add.<field>.max_length, the same in forms.buy_now, and declare forms in the section's ctx_needs; never write the numbers yourself. Show the email field only while forms.cart_add.gift_card_recipient_email.available is true and the phone field only while forms.cart_add.gift_card_recipient_phone.available is true: each says whether the store can deliver a card that way now (an active Gift Card delivery email template and a ready sender for email; the Gift Card delivery message sending on SMS or WhatsApp for phone). When neither is true the store cannot send a card to anyone else: leave out the whole send-as-gift switch, so the card goes to the buyer. The platform works available out only when a template reads it, once per request. Pages that read it stay cached at the edge: changing the store's Gift Card delivery email template, its email sender or its Gift Card text message route refreshes them, so the value is always current. Leaving the switch off sends the card to the buyer's own checkout email or phone. Contract rule: gift_card.recipient.
Field |
Contract |
|---|---|
|
A checkbox with value |
|
Optional. At most |
|
Shown only while |
|
Shown only while |
|
Optional. At most |
|
Optional: when to send the card. Leave it empty to send right after payment. Otherwise RFC 3339 with the shopper's offset, such as |
Instead of the separate fields, a script may post one gift_card_recipient field holding the JSON object {name, email, phone, message, deliver_at}; unknown keys are refused. tringify.cart.add and tringify.checkout.buyNow accept the same object. Field details: Cart actions.
The platform refuses a recipient it cannot accept (no email or phone, an invalid email or phone, a name or message that is too long, a send time that has passed) with GIFT_CARD_INVALID, and nothing is added. The response carries one message in the shopper's language: in user_errors (field gift_card_recipient) for a script, or in form_notice after a plain form post. Show it as given. Checking the fields in the browser first is a courtesy that saves a round trip; the platform's answer is the one that counts.
{%- if product.is_gift_card -%}
{%- assign by_email = forms.cart_add.gift_card_recipient_email.available -%}
{%- assign by_phone = forms.cart_add.gift_card_recipient_phone.available -%}
{%- if by_email or by_phone -%}
<fieldset class="gift">
<legend>{{ 'products.gift_recipient' | t }}</legend>
<label>
<input type="checkbox" role="switch" name="gift_card_send_as_gift" value="true" aria-controls="gift-{{ product.id }}">
{{ 'products.gift_send' | t }}
</label>
<p>{{ 'products.gift_send_hint' | t }}</p>
<fieldset id="gift-{{ product.id }}" disabled>
<label>{{ 'products.gift_name' | t }}
<input name="gift_card_recipient_name" maxlength="{{ forms.cart_add.gift_card_recipient_name.max_length }}" autocomplete="off"></label>
{%- if by_email -%}
<label>{{ 'products.gift_email' | t }}
<input type="email" name="gift_card_recipient_email" maxlength="{{ forms.cart_add.gift_card_recipient_email.max_length }}" autocomplete="off" aria-describedby="gift-{{ product.id }}-reach"></label>
{%- endif -%}
{%- if by_phone -%}
<label>{{ 'products.gift_phone' | t }}
<input type="tel" name="gift_card_recipient_phone" maxlength="{{ forms.cart_add.gift_card_recipient_phone.max_length }}" autocomplete="off" aria-describedby="gift-{{ product.id }}-reach"></label>
{%- endif -%}
<p id="gift-{{ product.id }}-reach">
{%- if by_email and by_phone -%}{{ 'products.gift_reach_hint' | t }}
{%- elsif by_email -%}{{ 'products.gift_reach_email' | t }}
{%- else -%}{{ 'products.gift_reach_phone' | t }}{%- endif -%}
</p>
<label>{{ 'products.gift_message' | t }}
<textarea name="gift_card_recipient_message" maxlength="{{ forms.cart_add.gift_card_recipient_message.max_length }}" rows="3"></textarea></label>
<label>{{ 'products.gift_date' | t }}
<input type="datetime-local" name="gift_card_deliver_at" aria-describedby="gift-{{ product.id }}-when"></label>
<p id="gift-{{ product.id }}-when">{{ 'products.gift_date_hint' | t }}</p>
</fieldset>
</fieldset>
{%- endif -%}
{%- endif -%}
A small script enables the inner fieldset while the switch is on. Without script the fieldset stays disabled and the card goes to the buyer, so either enable it in markup and rely on the platform's answer, or keep the switch and fields working without script as Canvas does. Before the form posts, a script that offers a local date picker writes the chosen moment into gift_card_deliver_at with the offset of that moment, so summer time is right:
function rfc3339(d) {
const pad = (n) => String(n).padStart(2, "0");
const off = -d.getTimezoneOffset(), a = Math.abs(off);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00` +
`${off < 0 ? "-" : "+"}${pad(Math.floor(a / 60))}:${pad(a % 60)}`;
}
// rfc3339(new Date(2026, 11, 24, 9, 0)) in New York gives "2026-12-24T09:00:00-05:00"
Canvas: the snippet sits in the product form of sections/main-product.vasc and sections/product-spotlight.vasc. It shows the email and phone fields by their available, words the hint and its "add a contact" message for the fields shown, and leaves out the whole switch when neither is available. Its switch is a checkbox with role="switch"; the phone field is Canvas's shared phone field; the message has a live counter; every maxlength and the counter's limit come from forms.cart_add; and with script the native date and time field becomes a picker with Now (after payment), Tomorrow morning and Pick a date, which writes gift_card_deliver_at as RFC 3339 with the shopper's offset and says when the card will send and in which time zone.
Recipient on cart lines#
A cart line for a card sent as a gift carries gift_card_recipient: name, email, phone (E.164, for machines), phone_display (the number in its country's international format), message, deliver_at (RFC 3339 in the store's timezone, for machines such as <time datetime>) and deliver_at_display: the same moment ready to show, in store time with the store's timezone named, in the shopper's language, such as "Fri, Oct 30, 9:00 AM (Hong Kong Time)" (the year appears when it is not this year). Both are null when the card sends right after payment. Show the whole recipient on the cart page and in the cart drawer, each part that is present: who the card is for, the email, phone_display, the message and when it sends. Print phone_display and deliver_at_display as given, never phone or a date you format yourself. A message can be long: clamp it to a couple of lines and keep the full text on the page (Canvas puts it in the element's title too). A line without a recipient (gift_card_recipient null) goes to the buyer.
{%- assign r = line.gift_card_recipient -%}
{%- if r -%}
{%- if r.name != blank -%}{%- assign to = r.name -%}{%- assign to_is = 'name' -%}
{%- elsif r.email != blank -%}{%- assign to = r.email -%}{%- assign to_is = 'email' -%}
{%- else -%}{%- assign to = r.phone_display -%}{%- assign to_is = 'phone' -%}{%- endif -%}
<p>{{ 'cart.gift_for' | t: name: to }}</p>
{%- if r.email != blank and to_is != 'email' -%}<p><bdi dir="ltr">{{ r.email }}</bdi></p>{%- endif -%}
{%- if r.phone_display != blank and to_is != 'phone' -%}<p><bdi dir="ltr">{{ r.phone_display }}</bdi></p>{%- endif -%}
{%- if r.message != blank -%}<p class="gift-message" dir="auto" title="{{ r.message }}">{{ 'cart.gift_message' | t: message: r.message }}</p>{%- endif -%}
{%- if r.deliver_at_display -%}
<p><time datetime="{{ r.deliver_at }}">{{ 'cart.gift_send_on' | t: date: r.deliver_at_display }}</time></p>
{%- endif -%}
{%- endif -%}
Show
phone_display, neverphone(contract rule phone.display; themecheckphone-displaywarns, including whenphonereaches the page through a variable such asassign to = r.email | default: r.phone).Show
deliver_at_display, neverdeliver_atthrough thedatefilter: the platform already wrote it in store time with the timezone named (contract rule time.display; themechecktime-displaywarns).The recipient is part of the line: two recipients or two messages make two lines, and the quantity buys that many cards for the same recipient. Update and remove the line with its
line_id. To change the recipient, remove the line and add the card again.Express checkout buttons do not offer a cart or product with a Gift Card:
express.reasonisEXPRESS_REASON_GIFT_CARD. Showexpress.reason_labelif you show a reason at all (Express checkout in themes).
Gift Card page#
/gift-cards checks a balance; /gift-cards/view/{token} opens one card from the View your gift card link in the Gift Card Delivery email or text message. Both work under the store's locale prefixes (such as /fr-ca/gift-cards), are marked noindex, are never cached, and return the store's 404 page when the merchant turns off the balance check in the Gift Card settings. Both render templates/gift_card.json. Declare gift_card, forms and form_error in the section's ctx_needs.
State |
What the section gets |
What to show |
|---|---|---|
First visit to |
|
The lookup form. |
After a successful lookup |
|
|
After a failed lookup |
|
The message as given, and the form. |
View link |
|
The code (with a copy button if you like), the message, the balance and the rest as above. |
View link that opens nothing |
|
The message as given, and the form. |
Checking a balance on the Gift Card page requires the code, not the recipient email or a matching customer account. The lookup posts the gift_card_lookup action with one field, code: trimmed, any letter case, dashes optional. Bind the input to forms.gift_card.code (required, max_length) rather than writing your own limits. The platform adds its own proof-of-work script to this page, so a plain form submits correctly. Lookups are rate limited per visitor and per store, and every refusal (unknown code, cancelled card, too many attempts) comes back as a message in form_error. Details: Gift Cards actions.
{%- if form_error and form_error.message != blank -%}
<p role="alert">{{ form_error.message }}</p>
{%- endif -%}
{%- if gift_card -%}
<article aria-labelledby="gc-balance">
{%- if gift_card.code != blank -%}
<p>{{ 'gift_card.code' | t }} <bdi dir="ltr">{{ gift_card.code }}</bdi></p>
{%- else -%}
<p><bdi dir="ltr">{{ gift_card.code_masked }}</bdi></p>
{%- endif -%}
<p>{{ gift_card.status_label }}</p>
<p><span id="gc-balance">{{ 'gift_card.balance' | t }}</span> {{ gift_card.balance.formatted }}</p>
<p>{{ 'gift_card.initial_value' | t }} {{ gift_card.initial_value.formatted }}</p>
{%- if gift_card.expires_at_label -%}<p>{{ gift_card.expires_at_label }}</p>{%- endif -%}
{%- if gift_card.message != blank -%}<blockquote>{{ gift_card.message }}</blockquote>{%- endif -%}
<a href="{{ gift_card.shop_url }}">{{ 'gift_card.shop' | t }}</a>
</article>
{%- endif -%}
<h2>{{ 'gift_card.check_title' | t }}</h2>
{% form 'gift_card_lookup' %}
<label>{{ 'gift_card.code' | t }}
<input name="code" autocomplete="off" autocapitalize="characters" spellcheck="false"
maxlength="{{ forms.gift_card.code.max_length }}"{% if forms.gift_card.code.required %} required{% endif %}></label>
<button type="submit">{{ 'gift_card.check' | t }}</button>
{% endform %}
Print
codeonly where it is not null, andcode_maskedotherwise. Never put a code in a URL, an analytics event, a cookie, storage or the console (contract rule gift_card.code).Print
form_error.messageas given. Do not write your own not-found text, and do not try to tell a wrong code from any other refusal (contract rule gift_card.message).status(active,used,expired,disabled,cancelled) is for styling only; printstatus_label.expires_atis RFC 3339 for scripts; printexpires_at_label.Theme Store demos show the page without a card: the form does not submit there and no balance is invented.
Canvas: sections/main-gift-card.vasc shows the card above the lookup form, a Copy code button that appears only when the browser can copy and the full code is on the page, and the code in <bdi dir="ltr"> so it reads correctly in right-to-left languages. All fields: Gift Card products and purchases and Shared data shapes.
My Gift Cards#
In the account template, the section that shows the customer's account declares customer.gift_cards in ctx_needs; the list is loaded only for a section that declares it. It holds items, page_size, next_url, first_url and active. Items come newest first, page_size (20) at a time. Print next_url and first_url as given (next_url is empty on the last page), and open your Gift Cards tab or panel when active is true, which means the shopper is paging through the list.
|
What it is |
Show |
|---|---|---|
|
A card sent to an email or phone the customer has confirmed, or one they bought for their own email or phone. |
|
|
A card the customer bought for someone else. |
|
delivery_status is scheduled, sending, sent or not_sent. sent means the card was handed off for delivery; the store does not know whether it arrived, so never word it as delivered: print delivery_status_label. Print kind_label if you label the two kinds. With no cards, show an empty state from your locale files. Canvas: the Gift Cards tab of sections/main-account.vasc.
Theme contract rules#
Rule |
In short |
|---|---|
Offer the recipient form on every Gift Card product form with its lengths from |
|
On the Gift Card page, print |
|
Print a code only where it is not null; never put it in a URL, analytics, a cookie or storage. |
|
Show the recipient's |
|
Print |
|
Every word comes from settings or locale keys through |
Strings to ship#
Everything the platform does not word, your theme words, through t and keys in its locale files. Put every key in your default locale file and in every other storefront locale file you ship (locales/<language>.json). A key missing from one language is not an error: the shopper sees your default language's text there, so check the files yourself. Merchants can reword any of these keys in Store Admin without editing your theme. Canvas ships these keys in all ten of its languages:
Where |
Canvas key |
English text |
|---|---|---|
Recipient form |
|
Recipient |
|
Send as a gift to someone |
|
|
Leave this off to receive the card yourself. |
|
|
Recipient name |
|
|
Recipient email |
|
|
Recipient phone |
|
|
Add an email or a phone number. With both, we send it by email. |
|
|
We send the card to this email. |
|
|
We send the card to this phone number. |
|
|
Message (optional) |
|
|
Add the recipient’s email or phone number. |
|
|
Add the recipient’s email. |
|
|
Add the recipient’s phone number. |
|
|
Start the phone number with + and the country code. |
|
Send time |
|
Send on (optional) |
|
Leave empty to send right after payment. Enter the time in UTC. |
|
|
Now (after payment) |
|
|
Sends right after payment. |
|
|
Tomorrow morning |
|
|
Pick a date… |
|
|
Sends {{ date }} at {{ time }}. |
|
|
Your time zone: {{ zone }}. |
|
|
Previous month |
|
|
Next month |
|
|
Time |
|
|
Done |
|
|
That time has passed. Choose a later time. |
|
Cart lines |
|
Gift for {{ name }} |
|
Message: {{ message }} |
|
|
Sends {{ date }} |
|
Gift Card page |
|
Gift Card |
|
Check a balance |
|
|
Enter your Gift Card code to see its balance and expiry date. |
|
|
Gift Card code |
|
|
Check balance |
|
|
Balance |
|
|
Original value |
|
|
Expiry |
|
|
Copy code |
|
|
Copied |
|
|
Shop now |
|
My Gift Cards |
|
Gift Cards |
|
You have no Gift Cards yet. |
|
|
Recipient |
|
|
Sent |
|
|
Scheduled for |
The key names are yours; only the coverage matters. Do not add keys for what the platform words: status_label, expires_at_label, kind_label, delivery_status_label, form_error.message, user_errors and form_notice messages, and express.reason_label already arrive in the shopper's language. Gift Card send times arrive ready to show (deliver_at_display, sent_at_display, scheduled_at_display); print them as given. Other dates go through the date filter, which writes them in the shopper's language. The editor labels of your Gift Card section live in the *.schema.json files, which need en, de, es, fr and it (Theme Locale Files).
To list the keys a language is missing compared with your default locale (here en), using jq:
jq -r 'keys[]' locales/en.json | sort > /tmp/default.keys
for f in locales/*.json; do
case "$f" in *.schema.json|locales/en.json) continue ;; esac
missing=$(jq -r 'keys[]' "$f" | sort | comm -23 /tmp/default.keys -)
[ -n "$missing" ] && printf '%s is missing:\n%s\n' "$f" "$missing"
done
Accessibility#
Give every field a visible
<label>. Group the recipient fields in a<fieldset>with a<legend>, and tie hints ("an email or a phone", "sends right after payment") to their fields witharia-describedby.Make the send-as-gift control a real checkbox (Canvas adds
role="switch") witharia-controlspointing at the fields it opens, and disable the hidden fields so keyboard and screen reader users do not land in them.Keep the native
datetime-localfield as the fallback for a date picker. A custom picker is a radio group for its choices and a grid for the calendar: arrow keys move by day and week, Home and End to the ends of the week, Page Up and Page Down by month, Escape closes it and returns focus to the control that opened it. Name each day in full witharia-label, mark today witharia-current="date", disable past days, follow the page language for day and month names and the first day of the week (Intl), and swap Left and Right in right-to-left languages.Announce the chosen send time and the shopper's time zone in a polite live region, and the message counter likewise.
Print refusals in an element with
role="alert". When your own browser check stops the form, usesetCustomValidityandreportValidityon the field with your locale text, so the browser reads it out.Wrap codes, emails and phone numbers in
<bdi dir="ltr">so they read correctly in right-to-left languages.
Checklist#
templates/gift_card.jsonhas your Gift Card section, and thegift_cardentry intheme.jsonrequires it.tringify theme checkpasses.The section declares
gift_card,formsandform_error, binds the code input toforms.gift_card.code, and postsgift_card_lookupwith a plain form.The page shows
codeonly on the view link andcode_maskedotherwise, the sender's message,balance,initial_value,status_label,expires_at_labeland a link toshop_url, and printsform_error.messageas given.Every Add to cart form that can show a Gift Card product has the recipient form inside it, with every
maxlengthfromforms.cart_add(formsdeclared), the email and phone fields shown only while theiravailableis true (and no send-as-gift switch when neither is), posted only while sending as a gift, and Buy now sends it too.gift_card_deliver_atis RFC 3339 with the shopper's offset, or its hint says the time is UTC.Cart page and cart drawer show the whole recipient: who each card is for, the email and
phone_display(neverphone), the message (clamped, full text kept) and when it sends (deliver_at_display, never a formatteddeliver_at).The account page declares
customer.gift_cards, lists received and sent cards, pages withnext_urlandfirst_url, opens the list whenactive, and printssent_at_display/scheduled_at_display, and never calls a sent card delivered.No word is written into markup; every gift card key is in the default locale file and in every other storefront locale file you ship.
On a development store with a Gift Card product: buy a card for yourself and for someone else (email, phone, a send time), turn off the Gift Card delivery email template or the text message route and see the form drop that field (and the whole switch when both are off), check its balance, open the View your gift card link from the email, and find it under My Gift Cards, in each language you ship and at phone and desktop widths.
Related: Gift Card products and purchases for the data, Cart actions and Gift Cards actions for the forms, and Theme contract for every rule.
Merchant apps edit Gift Card recipients, messages, language and delivery schedules only when the card response allows it with can_edit_delivery. Sent or in-flight cards and cards with any redemption or debit lock those details; expired, depleted, cancelled and disabled cards also refuse Resend. Resend uses the saved recipient and language. Saving a future delivery schedule creates or moves its waiting send; clearing it cancels that send and sends nothing immediately. Use the Store API card capabilities and reason wording directly in merchant integrations; these permissions do not change the shopper-facing theme contract.
Buying a gift card creates a liability, not sales revenue: gift card lines are excluded from gross, net and total sales, product sales, frequently bought together and repeat purchases. They are never taxed or discounted. Spending a gift card pays for goods, which count as sales once on the order; the payment method is gift_card. Store credit is reported as store_credit. Completed refunds to either balance reduce sales once, on the day they complete; pending or failed refunds do not. Gift cards can't be bought with a gift card or store credit. In mixed carts, gift cards apply first to the non-gift-card payable amount. Store credit then applies to that eligible amount remaining after gift cards, subject to the customer's balance and the store's per-order limits. Neither field appears for a gift-card-only cart. A direct checkout attempt is refused with GIFT_CARD_PURCHASE_TENDER_FORBIDDEN: “Gift cards can’t be bought with a gift card or store credit”. The checkout editor's SAMPLEGIFT and sample store credit follow the same rule and never spend real balances. Gift-card-only orders count as orders but are excluded from average order value. Average order value = total sales ÷ recognized orders that have sales lines. Free merchandise and fully refunded merchandise orders still have sales lines. A mixed order contributes only its sales amount. A sale notification shows that same amount; a gift-card-only order says Gift card sold and shows the gift card purchase amount. Checkout started, payment chosen and purchase event values and items exclude gift card lines.
Order and payment webhooks remain complete transaction records: their order totals include gift card purchases and their payment records include stored-value tender. They are not sales totals. Sales analytics and advertising events exclude gift card lines. Gift card movement webhooks describe liability changes, never new merchandise revenue. Timestamps are RFC 3339 in the store timezone.
Merchant balance adjustments appear in the existing balance values on the Gift Card page and customer account. Always render the current server-provided balance and status; adjustment reasons are merchant-only and must not be shown to shoppers. Cards issued by CSV follow the same Gift Card display and delivery contract as cards issued by hand. CSV exports contain only the last four code characters. See the Store API Gift Cards reference and Bulk changes with CSV imports for merchant operations.