# Custom domain

Serve verification on your own domain, fully white-label.


Your tags can carry your own web address. A customer taps and lands on
`verify.brand.com`. TAGBASE runs the check behind that name, and your brand is
the only one on screen.

## Why use it

- **Your name on the tag.** Customers never see a TAGBASE address.
- **Your page.** You build the screen the customer lands on.
- **The same check.** Nothing about the security changes.
- **One DNS record.** Plus a few lines of code on your page.

## Two addresses

This setup needs two web addresses, and they do different jobs.

| | Example | Points at | Set in |
| --- | --- | --- | --- |
| Custom domain | `verify.brand.com` | TAGBASE | Settings |
| Redirect URL | `www.brand.com/product` | Your own server | Each product |

The tag carries the custom domain. TAGBASE answers there, checks the tag, then
sends the customer on to your redirect URL.

Pick a subdomain you use for nothing else. TAGBASE answers every request that
reaches it. If you pick the one your website runs on, your website stops
loading.

## How it works

1. You point a subdomain at TAGBASE.
2. TAGBASE checks the record and switches the domain on.
3. Your tags get programmed with your address.
4. A customer taps. TAGBASE checks the tag, then sends them to your page with
   the verification id in a `tid` parameter.
5. Your page asks for the result and shows it.

## Hand it to your coding agent

Copy this brief into Claude Code, Cursor, or whatever you build with. It walks
the same steps as the setup below, in the same order, and your agent writes the
page from it.

```text
Add TAGBASE product verification to this site.

1. Before any code, point a subdomain of this site at TAGBASE. Add a CNAME
   from verify.<domain> to external.tagbase.io, then confirm the domain
   under Settings in the TAGBASE dashboard. Everything below asks that
   subdomain for the answer, so stop here until it resolves.
2. Load the client with one script tag. There is nothing to install:
   <script src="https://cdn.jsdelivr.net/npm/@tagbase-io/verify"></script>
   It puts verify and VerifyError on window.tagbase.
3. Build the page the tag sends people to. Call
   tagbase.verify({ baseUrl }), where baseUrl is the subdomain from step 1,
   for example https://verify.example.com. The tag puts the id of the
   result in the ?tid parameter and verify() reads it. Serve the page from
   the same domain as that subdomain, and set the product's redirect URL to
   the exact origin the page runs on.
4. Show what comes back: result.status, result.messages (the words for the
   customer), result.title, result.description, result.imageUrls,
   result.data and result.documents.
5. Write all four statuses. 'pending' asks for a second tap. 'valid' and
   'valid_with_warnings' show the product. 'invalid' warns the customer
   that the item may be a copy.
6. Read result.incognito before you render a status. verify() sets that
   field on the same request. Use it. Do not add a detector, and do not
   call anything before verify(). When incognito is true, tell the
   customer to leave private mode and open this page in a normal window.
   A pending status in that window is not a step they can finish.
7. Catch VerifyError. Its code is 'no_id', 'not_found', 'network',
   'server' or 'location_required'. Write a page for a tag we have no
   record of, and one that asks the customer to allow location access and
   reload when the code is 'location_required'.
8. Keep the same check when the page reloads. The id is in ?tid. Leave it
   in the address. On each load, call verify() once with that id. If you
   store an id for this visit, reuse it only when the address has no tid.
   A tid in the address replaces the stored id. When the page is already
   loading, pending, valid, valid_with_warnings, or invalid, keep that
   result. Do not start a second scan. Do not POST a verification.
   verify() reads a check that already exists. A pending check goes stale
   after ten minutes by default. Read result.insertedAt. Tell the customer
   to tap the tag again. When the address then carries a new tid, drop the
   stored id and show that new pending result. A valid,
   valid_with_warnings, or invalid result stays. Reload shows it again.

Read https://verify.tagbase.io/docs/your-domain/external-verification
first. Match this brief, including step 8. The demo at
https://customdomain.xyz shows the private-mode screen, and its source is
at https://github.com/tagbase-io/customdomain.xyz.
```

When that page is built, run the [checklist](#best-practices).

## Setup

### 1. Point your domain at TAGBASE

Add this record wherever your DNS lives:

| Type | Name | Value |
| --- | --- | --- |
| CNAME | `verify` | `external.tagbase.io` |

`verify.brand.com` is now a TAGBASE address under a name of yours. Confirm the
domain under **Settings**, then wait for it to go live. Nothing below works
until it does.

### 2. Load the client

Load it from a CDN. There is nothing to install. The script tag defines
`tagbase`, holding `verify` and `VerifyError`:

```html
<script src="https://cdn.jsdelivr.net/npm/@tagbase-io/verify"></script>
```

The URL follows the current release. Add `@0.2.0` to it to pin a version.
The client is [@tagbase-io/verify](https://www.npmjs.com/package/@tagbase-io/verify)
on npm, with the types and the version history.

### 3. Read the result

Call `verify` on the page your tags redirect to. It reads the id out of the
address and asks your custom domain for the result:

```javascript
const { verify } = tagbase

const result = await verify({ baseUrl: "https://verify.brand.com" })

if (result.status === "pending") {
  // result.messages[0].text: "Please tap again to complete the verification process"
}
```

`baseUrl` is the address from step 1, the one whose CNAME points at
`external.tagbase.io`. It is not the address your page runs on. The page reads
the id from `?tid=vrf_…` in its own address, so `?tid=` plus a real id is how
you open the page by hand. Pass `id` yourself if your router strips the
parameter.

Two rules about where your page lives. Break either one and the check stops
working, and the error you get says nothing about why.

- **Put your page on the same website.** TAGBASE saves a cookie on the first
  tap and reads it back on the second. Browsers hand that cookie back only to
  the same website. `www.brand.com` and `verify.brand.com` count as the same
  website. Host it anywhere else and the second tap fails.
- **Give the product your page's exact address.** That is the redirect URL on
  the product. Write it exactly as you serve the page. `www.brand.com` and
  `brand.com` are two different addresses. If they do not match, the browser
  blocks our answer and your code sees a `network` error.

### 4. What comes back

| Field | Description |
| --- | --- |
| `id` | The verification, `vrf_…` |
| `status` | `pending`, `valid`, `valid_with_warnings`, or `invalid` |
| `messages` | What to show the customer, in the order they should read it |
| `title`, `description` | The product behind this tag |
| `imageUrls` | Product images and video |
| `documents` | Files to offer, each with a `downloadUrl` |
| `website` | The product's own page, when the product has one |
| `data` | The tag's [custom data](/docs/dashboard/custom-data): category fields and raw JSON alike |
| `tamperStatus` | `sealed`, `resealed`, `open`, `invalid`, or `null` |
| `onDevice` | Whether this browser made the scan |
| `incognito` | Whether the customer browses privately |
| `insertedAt` | When the tag was tapped |

The first tap always reads `pending`. The second resolves it to `valid` or
`invalid`. `valid_with_warnings` means the product is real and something is
worth saying, so read `messages` for what. See
[how it works](/docs/overview/how-it-works).

`messages` arrive in English whatever language your customer reads. Translate
them yourself if you serve other languages.

### 5. Handle the failures

Every failure throws a `VerifyError` carrying a `code`. An unknown tag and an
unreachable server are different problems, so tell them apart before you write
an error message:

```javascript
const { verify, VerifyError } = tagbase

try {
  const result = await verify({ baseUrl: "https://verify.brand.com" })
} catch (error) {
  if (error instanceof VerifyError && error.code === "not_found") {
    // No record for this tag yet. Ask the customer to tap again.
  }
}
```

The codes are `no_id`, `not_found`, `network`, `server`, and
`location_required`. The last one only happens on a product that
[requires a location](#required): the customer refused the browser's prompt.
Show them how to allow location access and call `verify()` again.

### 6. Private browsing

The check needs the first tap and the second to find each other. Private
browsing keeps them apart. `verify()` sets `result.incognito` on the same
request. Read that field. Serve your page over HTTPS, or the field is
unreliable.

A private window never gets past `pending`. Render that status there and you
ask the customer to keep tapping a tag that will never resolve. Give them the
reason instead:

```javascript
if (result.incognito) {
  // Tell the customer to leave private mode and open this page in a normal window.
} else {
  // Render result.status.
}
```

### 7. Location

Where a tag was tapped is part of every scan. How the client asks for it
depends on the [location requirement](/docs/team-and-settings/verification-page)
of your team or the product.

#### Optional

The client asks the browser where it is and reports the answer against the
scan, so you can see where a tag was tapped. Your customer sees a permission
prompt. If they refuse, the result still arrives without a location.

By default, TAGBASE decides itself when to trigger the browser's location
request, so the customer only sees the prompt when the answer is useful.

Pass `geolocation: false` to switch it off. The prompt never appears:

```javascript
await verify({ baseUrl: "https://verify.brand.com", geolocation: false })
```

#### Required

When the location requirement is **Required**, the server keeps the result
until the scanning browser has shared a location. The client handles this on
its own: it asks the browser, sends the answer, and reads the result, whatever
`geolocation` says. A customer who refuses gets a `VerifyError` with the code
`location_required` instead of a result, see
[handle the failures](#5-handle-the-failures). Our
[location access](/docs/resources/location-access) page has the steps for
Safari and Chrome.

This needs the client at version 0.3 or newer. Older versions report the held
result as a server error.

### 8. Keep the same check

The id in `?tid` is the check. Leave it in the address. On a reload, call
`verify()` once with that id.

Store an id for this visit only as a fallback. Use the stored id when the
address has no `tid`. When the address has a `tid`, use that id and replace
the stored one.

Keep a result that is already on screen. That covers `loading`, `pending`,
`valid`, `valid_with_warnings`, and `invalid`. Do not start a second scan
for a result you already have.

This page reads a check that already exists. `verify()` does that read.
Do not POST a verification from the page.

A `pending` check times out after ten minutes by default. See
[scanning a tag](/docs/getting-started/scanning-a-tag). Read
`result.insertedAt`. Ask the customer to tap the tag again. The next tap
opens a new `pending` check and a new id. Drop the stored id and show
that check. A `valid`, `valid_with_warnings`, or `invalid` result stays
on screen. A reload shows that same result.

## The old response format

Requesting `accept: application/json` from `https://verify.brand.com/verifications/{id}`
returns an older flat `{data, messages}` body. This format is deprecated and
**stops working on 2026-12-31.**

Move to the client.

## Best practices

When the page from the brief above is in place, paste this into the same
agent. It checks the page and fixes what fails.

```text
The verification page is built. Check it, then fix what fails.

1. Private mode. The page reads result.incognito from the verify() result.
   A private window shows a message that tells the customer to leave
   private mode. Pending in that window is not a step they can finish.
   There is no call before verify(), and no second detector.
2. Reload while loading. Reload during the request. The page keeps the
   same tid. It does not open a second check.
3. Reload while pending. Reload on the pending screen. The same pending
   result comes back. The page does not start a new scan.
4. No duplicate POST. The page calls verify() and does not POST a
   verification.
5. Reuse. A reload uses the tid in the address. A stored id is used only
   when the address has no tid. A tid in the address replaces a stored id.
6. Stale pending. When result.insertedAt is older than ten minutes by
   default and the status is still pending, tell the customer to tap the
   tag again. When a new tid arrives, drop the old id and show the new
   pending result.
7. Finished screens. Reload on valid, on valid_with_warnings, and on
   invalid. Each screen comes back as it was. The page does not replace
   a finished result with a new check.

Read https://verify.tagbase.io/docs/your-domain/external-verification
first. The demo at https://customdomain.xyz shows the private-mode
screen, and its source is at https://github.com/tagbase-io/customdomain.xyz.
```

Stuck on the setup? Ask the agent for a human, or mail us. The
[Support](/docs/resources/support) page has both.

