Listing Claims
Let business owners claim listings they didn't submit — email-domain verification, admin review for edge cases, and an optional one-time fee, all behind the claims flag.
Why this matters
A directory seeded by CSV import has a built-in problem: every listing belongs to the admin who imported it. The real businesses have no way to take ownership, so nobody keeps their own hours, photos or contact details current. Claims fix that — an owner proves they run a listing and takes it over.
Turn it on
Flag: claims: true in config/features.config.ts (on by default; implied by the localBusiness vertical).
Migration: 0006_add_listing_claims.sql — adds is_claimed / claimed_at / claim_locked to apps plus a claims table.
Import as unclaimed: in the admin CSV importer, tick Mark imported listings as unclaimed so a freshly seeded directory is claimable from day one. Existing listings stay claimed — nothing changes until you opt in.
How a claim works
The flow is pending → email_verified → (awaiting_payment →) approved.
- An unclaimed listing shows an "Is this your business?" card that opens a 3-step dialog.
- The claimant enters a work email. If its domain matches the listing's
website_url(subdomains OK, free mailboxes like gmail.com never), they get a code and verify on the spot. - A mismatched domain isn't rejected — plenty of real owners can't send from their company domain — so the claim queues in the admin Claims screen for a human decision.
- On success, ownership transfers and the owner can edit the listing.
Codes are stored only as a SHA-256 hash and compared in constant time. TTL, attempt cap and resend cooldown are all config. Failed attempts are counted on the claim row, so rotating IPs doesn't reset them.
Charge for it (or don't)
Set the one-time fee in config/claims.config.ts:
export const claimsConfig = {
price: 0, // major units — 0 = free, and the payment step is skipped entirely
// verification code rules, freemail domains, ...
};Any non-zero price bills through a dynamic Stripe price_data line — there's no Stripe Price to create. The webhook branch is metadata.type === "listing_claim". See Payments.
A listing can only ever be handed over once. finalizeClaim() is idempotent and backed by a partial unique index (claims(app_id) WHERE status = 'approved'), so a Stripe webhook and an admin approval landing at the same moment can't double-transfer.
Where owners and admins manage claims
- Owner: a Claims tab in the dashboard tracks their requests.
- Admin: a Claims screen (nav item appears when the flag is on) reviews queued claims — approve or reject.