@brandlab-digital/site-next
v3.17.0
Published
Proprietary software © Brandlab Digital Marketing. All rights reserved. Published for Brandlab client sites.
Readme
@brandlab-digital/site-next
Proprietary software © Brandlab Digital Marketing. All rights reserved. Published for Brandlab client sites.
Reviews: refresh on change, not every day (3.13.0)
The shared reviews fetch (getBusinessProfileReviews, ./google-business-reviews/server) carries the tags
brandlab-reviews and brandlab-reviews:<site>. By default it still keeps a successful answer for one day
(BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS), so a site that installs 3.13.0 for another reason keeps daily reviews.
A site opts into refresh on change by passing refresh: 'on-change', in the same call that already names the site,
once it has the route below deployed with its secret and is registered with the daily reviews checker:
// src/lib/reviews.ts
getBusinessProfileReviews({ site: 'hr-kitchens', refresh: 'on-change' })The answer is then kept for 7 days (BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS) as a safety net only.
Once a day the checker hashes each site's feed and, when the reviews have really changed, calls the site's signed
revalidate route, which refreshes the tag. A new review still shows within a day; pages are rewritten only after a
real change. If a call fails, pages refresh themselves within the week. The choice sits on the fetch, not in
brandlab.config.ts, because the fetch is where Next reads it at build time and a site's config file is not imported
at runtime.
The route, at src/app/api/brandlab/reviews-revalidate/route.ts:
import { createBrandlabReviewsRevalidateRoute } from '@brandlab-digital/site-next/google-business-reviews/server'
export const { POST } = createBrandlabReviewsRevalidateRoute({ site: 'hr-kitchens' })Set BRANDLAB_REVALIDATE_SECRET (at least 32 characters) in the site's Vercel project and on the checker; never in
git. The call contract:
| | |
| --- | --- |
| Request | POST /api/brandlab/reviews-revalidate, body {"site":"<slug>","hash":"<64 hex>"} (1 KB at most) |
| Signature | x-brandlab-timestamp: <Unix seconds> and x-brandlab-signature: v1=<hex HMAC-SHA256 of "<timestamp>.<body>">; site-core createBusinessProfileReviewsRevalidateRequest builds both |
| Hash | site-core hashBusinessProfileReviews(feed): the rating, the count and each review's id, rating, text, author, times and reply, sorted by id |
| Who decides | The checker keeps the last hash each site accepted with a 200 and calls only when today's differs. The route also skips a hash it already applied, but only per server instance |
| 200 | { ok: true, revalidated: true } after revalidateTag('brandlab-reviews:<site>', 'max'), or revalidated: false, reason: 'unchanged' |
| Errors | 400 malformed body or another site's slug; 401 missing or wrong signature, or a timestamp more than 5 minutes off; 413 body over 1 KB; 429 with Retry-After past 10 requests per IP in 10 minutes; 503 secret not set; 500 refresh failed. The checker records the hash only after a 200 |
Refused requests log one line without the secret or signature, for example
{"event":"brandlab_reviews_revalidate","site":"hr-kitchens","result":"refused","reason":"signature"}.
Estate segment preset (./estate-segment)
Service-in-suburb and suburb pages export revalidate = false, written literally because Next reads segment config
statically, and a site test calls assertBrandlabEstateSegment(page). Deploys and the reviews tag rewrite them. A
page that shows reviews follows the reviews fetch: daily by default, weekly with refresh: 'on-change'. Ready-gate
check W3 (full tier, from 3.13.0) fails an estate family that refreshes more often than every 7 days, so a full-tier
estate that shows reviews adopts on-change with the route, or records the family in siteSystem.refreshExceptions
in brandlab.config.ts: { family, revalidateSeconds, reason, decisionId }.
Contact delivery: accepted is not delivered (3.14.0)
When Resend accepts an enquiry it is queued, not delivered. Resend can still bounce it, drop it because the business
inbox is on the account's suppression list, or have it reported as spam, while the site has already said "sent". That
is how BL Roofing lost enquiries. contact_form keeps its analytics-v4 meaning (Resend accepted the enquiry) and fires
exactly as before; delivery is now tracked beside it.
Before sending. createBrandlabContactRouteHandler asks Resend's suppression list about each business inbox
(GET /suppressions/{email}, cached for 5 minutes per inbox, at most 2.5 seconds per lookup). The check only
records and alerts; it never blocks a send. When an inbox is suppressed the route logs and alerts
recipient_suppressed, and the enquiry still goes to the client's own inbox (never rerouted, never copied to Brandlab)
with the normal "sent" result and contact_form as usual. Keeping the inbox and its email billing in order is the
client's responsibility; the alert lets Brandlab tell them the same day. A site can opt in to refusing instead with
refuseWhenAllSuppressed: true: when every inbox is suppressed the visitor then gets the form's "could not send,
please call" answer and no conversion fires. A failed or refused lookup never stops an enquiry: it is
sent as before and logged once per 5 minutes as brandlab_contact_suppression_check_unavailable. The lookup needs a
full-access key: set RESEND_LOOKUP_API_KEY so RESEND_API_KEY can stay sending-only (without it the lookup uses
RESEND_API_KEY). A sending-only key is refused with restricted_api_key, and the warning line names the missing
permission ("needs":"full-access key (RESEND_LOOKUP_API_KEY)"). suppressionCheck: false turns the check off; a
route that replaces the provider with createResendSend (synthetic tests) has it off unless it passes one.
After sending. A signed webhook route records what happened to the business copy. Add
src/app/api/brandlab/resend-webhook/route.ts:
import { createBrandlabResendWebhookRoute } from '@brandlab-digital/site-next/contact/server'
import { contactContract } from '@/config/contact'
export const { POST } = createBrandlabResendWebhookRoute({ projectId: contactContract.projectId })Then, in plain steps:
- Deploy the route.
- In Resend, open Webhooks and add an endpoint:
https://<the site's domain>/api/brandlab/resend-webhook. - Tick the email events: delivered, delivery delayed, bounced, complained, suppressed and failed.
- Open the new webhook and copy its signing secret (it starts with
whsec_). - In the site's Vercel project, add
RESEND_WEBHOOK_SECRETwith that value for Production (and Preview if previews send real enquiries). Never put it in git or.env.examplevalues. - Redeploy, send a test enquiry (
?bl_test=1), and check the logs show abrandlab_contact_delivery_statusline with"state":"delivered".
Resend webhooks cover the whole account, so each site's route records only the emails its own contact route sent (the
project_id tag) and answers 200 to the rest. The signature is checked the Svix way Resend documents
(HMAC-SHA256 over <svix-id>.<svix-timestamp>.<raw body>, 5 minutes' clock tolerance); a bad signature gets 401, a
missing secret 503. Failed attempts are rate-limited per x-real-ip (10 in 10 minutes, then 429 with Retry-After;
rateLimit adjusts it or false turns it off); a correctly signed request never counts. A retry with an svix-id the
instance already recorded answers 200 duplicate: true without logging again; that list is small and per instance,
so it is best effort and the monitor still de-duplicates.
Both halves accept onDeliveryFailure(failure), a same-day alert hook (email, Slack or a durable store). It gets the
code, source, enquiry reference and Resend email id, never an address or form value. If it throws on a webhook event
the route answers 500 so Resend retries the event.
| Environment variable | Where | What |
| --- | --- | --- |
| RESEND_API_KEY | Vercel, Production and Preview | Sends enquiries; can stay a sending-only key |
| RESEND_LOOKUP_API_KEY | Vercel, Production and Preview (optional) | A full-access key used only for the suppression lookup; without it the lookup uses RESEND_API_KEY |
| RESEND_WEBHOOK_SECRET | Vercel, Production | The webhook's whsec_ signing secret |
| the contract's delivery.recipientEnv | Vercel, Production and Preview | The business inbox |
For the daily delivery monitor (P26). Read these JSON lines from the site's logs:
| Line | Meaning | Action |
| --- | --- | --- |
| {"event":"brandlab_contact_delivery_failed",…,"code":"recipient_suppressed","source":"pre-send","recipients":"all"} | The inbox is suppressed; the enquiry was still sent (Resend will drop it) and the visitor saw "sent" (unless the site opted in to refuseWhenAllSuppressed: true, when it was not sent and the visitor was asked to call) | Same day: tell the client to fix the inbox, remove it from Resend → Suppressions, tell Romie |
| same, "recipients":"some" | One of several inboxes is suppressed; the others got it | Same day: fix or remove that inbox |
| …"code":"recipient_bounced"/"recipient_complained"/"recipient_suppressed"/"provider_failed","source":"webhook" | Resend accepted it but the business copy did not arrive (detail gives Resend's bounce type, suppression type or failure reason) | Same day: the enquiry counted as a contact_form but the business never saw it; find it by submission (the first 16 characters of the confirmation id) or emailId in Resend and forward it |
| {"event":"brandlab_contact_delivery_status","state":"delivered"} / "state":"delayed" | Delivered, or Resend is retrying a soft bounce | None; a delayed with no later delivered or failure within a day needs a look |
| brandlab_contact_suppression_check_unavailable | The pre-send check could not ask Resend (code says why; needs names a missing permission) | Set a full-access RESEND_LOOKUP_API_KEY; enquiries still send |
| brandlab_contact_delivery_alert_failed | The alert hook failed | Check the hook |
De-duplicate webhook lines on webhookId (Resend retries an event with the same id). Compare each day's
contact_form count in GA4 with delivered lines: every accepted enquiry should end in exactly one delivered or one
failure. Ready-gate check H13 (enquiry tier, from 3.14.0) checks the same inbox before launch: a real sender, MX records,
the sender domain verified in Resend and the inbox not suppressed. Run brandlab-ready --launch with the inbox variable
and a full-access key (RESEND_LOOKUP_API_KEY) set in the shell for that run, never written to a file. Without them H13
reports "not run" and names what to set: in an ordinary run that does not block the tier, and under --launch it fails.
Contact route: abuse limits and the test marker (3.10.0)
createBrandlabContactRouteHandler limits distinct enquiries per client IP and per email address where enquiries are
delivered (production and Preview): 10 per IP in 10 minutes and 5 per address in an hour by default
(BRANDLAB_CONTACT_RATE_LIMITS), held in memory per server instance. A resend of the same enquiry never counts, an
enquiry that fails validation never counts, and a refused one gets a retryable 429 with Retry-After and is never
sent. Adjust or turn them off with rateLimit: { perIp, perAddress } | false.
The in-memory limiter is the first layer. The second is a Vercel Firewall rate-limit rule on the route, which holds across every instance. Template (Vercel CLI, run in the linked project; check the project's Firewall tab for the plan's rate-limit support first, and keep the limit above the route's own):
vercel firewall rules add "Contact form rate limit" \
--condition '{"type":"path","op":"eq","value":"/api/contact"}' \
--condition '{"type":"method","op":"eq","value":"POST"}' \
--action rate_limit \
--rate-limit-window 600 \
--rate-limit-requests 20 \
--rate-limit-keys ip \
--rate-limit-action rate_limit --yesA Brandlab test enquiry (a launch proof or canary) opens the page with ?bl_test=1 (remembered for the tab; ?bl_test=0
ends it). Each enquiry then carries bl_test=1: the server delivers it to the configured recipient with [Test] in
the subject, never writes the marker into the email and answers without a confirmation, so the browser never sends
contact_form or an Ads conversion for it, and a test visit sends no lead_form_start or lead_form_error. Canary
previews deliver only to the Brandlab test inbox.
Canary and launch-proof runs count against the per-address limit (5 per address per hour), so use plus-addressed test
inboxes, one per run or per site (test+abloom-1@…, test+abloom-2@…), and spread repeated runs across them. A refused
enquiry logs one line: {"event":"brandlab_contact_rate_limited","rule":"address","retryAfterSeconds":1800}.
