Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions database/database-generated.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,7 @@ export type Database = {
max_form_responses_by_customer: number | null
max_form_responses_in_total: number | null
name: string
notification_respondent_email: Json
project_id: number
scheduling_close_at: string | null
scheduling_open_at: string | null
Expand All @@ -1241,6 +1242,7 @@ export type Database = {
max_form_responses_by_customer?: number | null
max_form_responses_in_total?: number | null
name?: string
notification_respondent_email?: Json
project_id: number
scheduling_close_at?: string | null
scheduling_open_at?: string | null
Expand All @@ -1262,6 +1264,7 @@ export type Database = {
max_form_responses_by_customer?: number | null
max_form_responses_in_total?: number | null
name?: string
notification_respondent_email?: Json
project_id?: number
scheduling_close_at?: string | null
scheduling_open_at?: string | null
Expand Down
39 changes: 39 additions & 0 deletions database/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ type SystemSchema_Favicon = {

type DBDocType = DatabaseGenerated["public"]["Enums"]["doctype"];

/**
* `grida_forms.form.notification_respondent_email`
*
* DB-enforced JSON schema (see migration):
* - optional keys only
* - no additional properties
*/
export type FormNotificationRespondentEmailConfig = {
enabled?: boolean;
from_name?: string | null;
subject_template?: string | null;
body_html_template?: string | null;
reply_to?: string | null;
};

// Override the type for a specific column in a view:
export type Database = MergeDeep<
DatabaseGenerated,
Expand Down Expand Up @@ -108,6 +123,30 @@ export type Database = MergeDeep<
};
};
};
grida_forms: {
Tables: {
form: {
Row: Omit<
DatabaseGenerated["grida_forms"]["Tables"]["form"]["Row"],
"notification_respondent_email"
> & {
notification_respondent_email: FormNotificationRespondentEmailConfig;
};
Insert: Omit<
DatabaseGenerated["grida_forms"]["Tables"]["form"]["Insert"],
"notification_respondent_email"
> & {
notification_respondent_email?: FormNotificationRespondentEmailConfig;
};
Update: Omit<
DatabaseGenerated["grida_forms"]["Tables"]["form"]["Update"],
"notification_respondent_email"
> & {
notification_respondent_email?: FormNotificationRespondentEmailConfig;
};
};
};
};
grida_west_referral: {
Views: {
campaign_public: {
Expand Down
5 changes: 5 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
unlisted: true
---

# Docs agent guide (`/docs`)

This directory is the **source of truth** for documentation content.
Expand Down Expand Up @@ -44,6 +48,7 @@ unlisted: true
| [/docs/math](./math) | math | Math reference, used for internal docs referencing | yes |
| [/docs/platform](./platform) | platform | Grida Platform (API/Spec) documents | yes |
| [/docs/editor](./editor) | editor | Grida Editor - User Documentation | yes |
| [/docs/forms](./forms) | forms | Grida Forms - User Documentation | yes |
| [/docs/canvas](./canvas) | canvas | Grida Canvas SDK - User Documentation | no |
| [/docs/cli](./cli) | cli | Grida CLI - User Documentation | yes |
| [/docs/together](./together) | together | Contributing, Support, Community, etc | yes |
Expand Down
85 changes: 85 additions & 0 deletions docs/forms/respondent-email-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
title: Respondent email notifications
description: Learn how to send a custom confirmation email to respondents after a form submission in Grida Forms (CIAM verified email required).
---

### Respondent email notifications

Respondent email notifications let you send a **custom confirmation email** to the person who submitted your form.

This is useful for signup and registration forms where you want to:

- confirm the submission
- share next steps
- include a reference like a submission ID

### Before you start (CIAM / verified email)

Grida sends respondent emails **only when CIAM is used** and the respondent has a **verified email**.

Practically, this means:

- your form should include a `challenge_email` field (CIAM email verification)
- the email is sent to the verified email associated with the submission (not to an arbitrary input field)

### How to enable respondent email notifications

1. Open your **Form** in the Grida editor.
2. In the left sidebar, click **Connect**.
3. Click **Channels**.
4. Under **Email Notifications**, find **Respondent email notifications**.
5. Toggle **Enable** on.
6. Click **Save**.

### How to customize the email

1. Open **Connect → Channels → Email Notifications** (same as above).
2. Configure the email fields:
- **Reply-To** (optional): where replies should go (e.g. `support@yourdomain.com`)
- **Subject**: the email subject template
- **From name** (optional): the sender display name (e.g. `Acme Support`)
- **Body (HTML)**: the email body template (HTML)
3. Use the built-in preview to check your subject/body.
4. Click **Save**.

### What gets sent (high level)

- **Recipient**: the respondent’s **verified email** (CIAM)
- **From email**: a fixed no-reply address (display name can be customized with **From name**)
- **When it sends**: after a successful form submission
- if CIAM isn’t present or email isn’t verified, the email is skipped

### Templating (Handlebars variables)

Subject and Body support template variables.

#### Available variables

- `{{form_title}}`
- `{{response.idx}}` (formatted submission index)
- `{{fields.<field_name>}}` (submitted fields by field name)

#### Examples

Subject:

```txt
Thanks for registering for {{form_title}}
```

Body (HTML):

```html
<h1>Thanks, {{fields.first_name}}!</h1>
<p>We received your submission for {{form_title}}.</p>
<p>Your registration number: {{response.idx}}</p>
```

### Troubleshooting

If emails are not being sent:

- **CIAM not enabled**: ensure your form includes a `challenge_email` field
- **Email not verified**: respondent must complete verification; unverified emails are skipped
- **Missing body template**: sending is skipped if the body is empty
- **Delivery reliability**: sending is currently best-effort inline. Retries/queueing may be added later.
12 changes: 9 additions & 3 deletions editor/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ NEXT_PUBLIC_OPENAI_BEST_MODEL_ID="gpt-4o-mini"
# resend
RESEND_API_KEY='re_123'

# s2s private api key (intent key)
# used to guard public endpoints that perform privileged actions server-side
# must match request header: `x-grida-s2s-key`
GRIDA_S2S_PRIVATE_API_KEY="replace-with-a-long-random-secret"
# internal (proxy -> internal api auth)
# used to authenticate requests from `proxy.ts` to `/internal/resolve-host`
GRIDA_INTERNAL_PROXY_TOKEN="replace-with-a-long-random-secret"

# upstash redis (used for rate limiting)
# @see https://upstash.com/docs/redis/overall/getstarted
UPSTASH_REDIS_REST_URL=""
Expand All @@ -57,9 +65,7 @@ VERCEL_TEAM_ID=''
# Vercel Project ID that can be found here: https://vercel.com/<org>/<project>/settings
VERCEL_PROJECT_ID=''

# internal (proxy -> internal api auth)
# used to authenticate requests from `proxy.ts` to `/internal/resolve-host`
GRIDA_INTERNAL_PROXY_TOKEN=""



# telemetry (sentry)
Expand Down
26 changes: 26 additions & 0 deletions editor/app/(api)/(public)/v1/submit/[id]/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Env } from "@/env";
import { resend } from "@/clients/resend";
import EmailTemplate from "@/theme/templates-email/formcomplete/default";

const GRIDA_S2S_PRIVATE_API_KEY = process.env.GRIDA_S2S_PRIVATE_API_KEY;

const bird = new Bird(
process.env.BIRD_WORKSPACE_ID as string,
process.env.BIRD_SMS_CHANNEL_ID as string,
Expand Down Expand Up @@ -51,6 +53,30 @@ export namespace OnSubmit {
}),
});
}

export async function notification_respondent_email({
form_id,
response_id,
}: {
form_id: string;
response_id: string;
}) {
return fetch(
`${Env.server.HOST}/v1/submit/${form_id}/hooks/notification-respondent-email`,
{
headers: {
"Content-Type": "application/json",
...(GRIDA_S2S_PRIVATE_API_KEY
? { "x-grida-s2s-key": GRIDA_S2S_PRIVATE_API_KEY }
: {}),
},
method: "POST",
body: JSON.stringify({
response_id,
}),
}
);
}
}

export namespace OnSubmitProcessors {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from "next/server";
import assert from "assert";
import { notFound } from "next/navigation";
import validator from "validator";
import { service_role } from "@/lib/supabase/server";
import { resend } from "@/clients/resend";
import { renderRespondentEmail } from "@/services/form/respondent-email";

type Params = { id: string };

/**
* Guard this public hook endpoint with a shared S2S key.
*
* This route uses `service_role` and can send emails, so it must not be
* callable by arbitrary third-parties.
*/
const GRIDA_S2S_PRIVATE_API_KEY = process.env.GRIDA_S2S_PRIVATE_API_KEY;

export async function POST(
req: NextRequest,
context: {
params: Promise<Params>;
}
) {
const provided =
req.headers.get("x-grida-s2s-key") ?? req.headers.get("x-hook-secret");
if (!GRIDA_S2S_PRIVATE_API_KEY) {
console.error(
"notification-respondent-email/err/misconfigured: GRIDA_S2S_PRIVATE_API_KEY missing"
);
return NextResponse.json({ ok: false }, { status: 500 });
}
if (!provided) {
return NextResponse.json({ ok: false }, { status: 401 });
}
if (provided !== GRIDA_S2S_PRIVATE_API_KEY) {
return NextResponse.json({ ok: false }, { status: 403 });
}

const { id: form_id } = await context.params;
const { response_id } = await req.json();

assert(form_id, "form_id is required");
assert(response_id, "response_id is required");

const { data: form, error: form_err } = await service_role.forms
.from("form")
.select(
`
id,
title,
notification_respondent_email,
fields:attribute(id, name, type)
`
)
.eq("id", form_id)
.single();

if (form_err)
console.error("notification-respondent-email/err/form", form_err);
if (!form) return notFound();
Comment on lines +40 to +61
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts

Repository: gridaco/grida

Length of output: 4778


🌐 Web query:

Next.js 16 notFound() function API routes JSON response

💡 Result:

notFound() is meant for App Router UI rendering: it throws an internal 404 error so Next.js renders your segment’s not-found page (it’s not a “return a JSON 404” helper). [1]

For an API endpoint (Route Handler in app/api/**/route.ts), return a normal 404 response with JSON:

// app/api/users/[id]/route.ts
export async function GET(_: Request, { params }: { params: { id: string } }) {
  const user = await getUser(params.id)
  if (!user) return Response.json({ error: "Not found" }, { status: 404 })
  return Response.json(user)
}

Response.json(...) / NextResponse.json(...) is the intended way to send JSON from Route Handlers. [2]

If you’re using Pages Router API routes (pages/api/...), do:

res.status(404).json({ error: "Not found" })

Sources
[1] Next.js Docs – notFound() API reference https://nextjs.org/docs/app/api-reference/functions/not-found
[2] Next.js Docs – Route Handlers (returning JSON via Response.json) https://nextjs.org/docs/app/getting-started/route-handlers-and-middleware


🌐 Web query:

Next.js 16 request.json() error handling try-catch

💡 Result:

In Next.js 16 Route Handlers (app/api/**/route.ts), request.json() is just the standard Web Request.json() method. It returns a Promise and can throw:

Minimal pattern (catch invalid JSON → 400)

// app/api/foo/route.ts
export async function POST(request: Request) {
  let body: unknown;

  try {
    body = await request.json(); // call ONCE
  } catch (err) {
    return Response.json(
      { error: "Invalid JSON" },
      { status: 400 }
    );
  }

  return Response.json({ ok: true, body });
}

Next.js explicitly documents reading the body this way in Route Handlers. (nextjs.org)

Avoid “Body is unusable” (don’t read twice)

If you do something like console.log(await request.json()) and then later await request.json() again, the second read can fail because the body stream is already consumed. (reddit.com)

If you need the body twice, read it once and reuse the value, or use request.clone() before reading.

If you want to treat empty body as “no JSON” (optional)

const text = await request.text();
if (!text) return Response.json({ ok: true, body: null });

let body;
try { body = JSON.parse(text); }
catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }

Citations:


Return explicit JSON 400/404 instead of throwing.

assert() raises 500s on bad input, and notFound() is designed for page routing (not JSON API responses). Additionally, req.json() can throw SyntaxError on invalid JSON, which will also result in a 500 response instead of a proper 400 error. Use try-catch for JSON parsing and return NextResponse.json() with appropriate status codes for validation failures and missing resources.

✅ Suggested error handling pattern
@@
-import assert from "assert";
-import { notFound } from "next/navigation";
@@
   const { id: form_id } = await context.params;
-  const { response_id } = await req.json();
-
-  assert(form_id, "form_id is required");
-  assert(response_id, "response_id is required");
+  let body: { response_id?: string };
+  try {
+    body = await req.json();
+  } catch {
+    return NextResponse.json(
+      { ok: false, error: "invalid_json" },
+      { status: 400 }
+    );
+  }
+  const { response_id } = body ?? {};
+  if (!form_id || !response_id) {
+    return NextResponse.json(
+      { ok: false, error: "missing_params" },
+      { status: 400 }
+    );
+  }
@@
-  if (!form) return notFound();
+  if (!form)
+    return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });

Also applies to: 62-65, 82-83

🤖 Prompt for AI Agents
In
`@editor/app/`(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts
around lines 17 - 38, Replace throwing asserts and route-only notFound() with
explicit JSON responses and guard req.json() parsing: wrap the req.json() call
in try/catch and return NextResponse.json({ error: "Invalid JSON" }, { status:
400 }) on parse errors; validate extracted values (context.params.id /
response_id) and if missing return NextResponse.json({ error: "form_id required"
} or { error: "response_id required" }, { status: 400 }) instead of using
assert(); when the DB call for service_role.forms (the query selecting
id,title,notification_respondent_email,fields) returns no form, return
NextResponse.json({ error: "Not found" }, { status: 404 }) instead of calling
notFound(); keep existing console.error logging for form_err but ensure any
server-side failures still return a JSON 500 response via NextResponse.json({
error: "Internal Server Error" }, { status: 500 }).


const cfg = form.notification_respondent_email;

if (!cfg.enabled) {
return NextResponse.json(
{ ok: true, skipped: "disabled" },
{ status: 200 }
);
}

const { data: response, error: response_err } = await service_role.forms
.from("response")
.select("id, form_id, raw, local_index, local_id, customer_id")
.eq("id", response_id)
.eq("form_id", form_id)
.single();

if (response_err)
console.error("notification-respondent-email/err/response", response_err);
if (!response) return notFound();

const customer_id = response.customer_id?.trim() || null;
if (!customer_id) {
return NextResponse.json(
{ ok: true, skipped: "missing_customer" },
{ status: 200 }
);
}

const { data: customer, error: customer_err } = await service_role.workspace
.from("customer")
.select("uid, email, is_email_verified")
.eq("uid", customer_id)
.single();

if (customer_err)
console.error("notification-respondent-email/err/customer", customer_err);
if (!customer) return notFound();

const to = (customer.email ?? "").trim();
if (!to || !customer.is_email_verified || !validator.isEmail(to)) {
return NextResponse.json(
{ ok: true, skipped: "unverified_email" },
{ status: 200 }
);
}

const raw = (response.raw ?? {}) as Record<string, unknown>;

const htmlSource = cfg.body_html_template?.trim();
if (!htmlSource) {
return NextResponse.json(
{ ok: true, skipped: "missing_body_template" },
{ status: 200 }
);
}

const { subject, html } = renderRespondentEmail({
form_title: form.title,
raw,
response_local_index: Number(response.local_index ?? 0),
response_local_id: response.local_id ?? null,
subject_template: cfg.subject_template ?? null,
body_html_template: htmlSource,
});

const replyTo = cfg.reply_to?.trim() || undefined;
const replyToSafe =
replyTo && validator.isEmail(replyTo) ? replyTo : undefined;

const fromName = cfg.from_name?.trim() || "Grida Forms";

try {
await resend.emails.send({
from: `${fromName} <no-reply@accounts.grida.co>`,
to: [to],
subject,
html,
replyTo: replyToSafe,
tags: [
{ name: "type", value: "notification_respondent_email" },
{ name: "form_id", value: form_id },
],
});
} catch (e) {
console.error("notification-respondent-email/err/send", e);
return NextResponse.json({ ok: false }, { status: 500 });
}

return NextResponse.json({ ok: true }, { status: 200 });
Comment on lines 19 to 151
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the directory structure around this endpoint
echo "=== Directory structure of public submit API ==="
find editor/app/\(api\)/\(public\)/v1/submit -type f -name "*.ts" | head -30
</parameter>

Repository: gridaco/grida

Length of output: 652


🏁 Script executed:

#!/bin/bash
# Look for how this endpoint is called - client-side invocations
echo "=== Search for POST calls to notification-respondent-email ==="
rg -n "notification-respondent-email" -g '*.{ts,tsx,js,jsx}' -A 3 -B 3 --max-count=15

echo ""
echo "=== Search for where response_id is passed to the client ==="
rg -n "response_id" -g '*.{ts,tsx,js,jsx}' -B 2 -A 2 | head -50
</parameter>

Repository: gridaco/grida

Length of output: 11615


🏁 Script executed:

#!/bin/bash
# Check for idempotency or rate limiting mechanisms
echo "=== Search for notification_sent_at, idempotency, or rate limit flags ==="
rg -n "(notification_sent|idempotenc|rate_limit|sent_at)" -g '*.{ts,tsx}' --max-count=30

echo ""
echo "=== Check the hooks.ts file to see how the endpoint is called ==="
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks.ts | head -80
</parameter>

Repository: gridaco/grida

Length of output: 2846


🏁 Script executed:

#!/bin/bash
# Check the main submit route to see context of hook invocation
echo "=== Check how notification hook is invoked in main submit handler ==="
rg -B 5 -A 5 "notification-respondent-email" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/route.ts | head -50
</parameter>

Repository: gridaco/grida

Length of output: 555


🏁 Script executed:

#!/bin/bash
# Check if the fetch call has any auth headers or signing
echo "=== Full hooks.ts to check for auth/signing ==="
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks.ts

echo ""
echo "=== Check response table schema for notification tracking ==="
rg -n "response" -g '*.ts' "database.types" | grep -i "notification\|sent" | head -20
</parameter>

Repository: gridaco/grida

Length of output: 4686


🏁 Script executed:

#!/bin/bash
# Check if response_id is truly public/exposed
echo "=== Search for where response is returned to client after submit ==="
rg -B 3 -A 3 "response_reference_obj.id" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/route.ts | head -40
</parameter>

Repository: gridaco/grida

Length of output: 1279


🏁 Script executed:

#!/bin/bash
# Check the response table schema to confirm no notification tracking exists
echo "=== Search database schema files ==="
find editor -name "*.ts" -path "*database*" -o -name "database.types.ts" 2>/dev/null | head -10

echo ""
echo "=== Search for response table type definition ==="
rg -n "interface.*[Rr]esponse\|type.*[Rr]esponse" -g '*.ts' --max-count=5 | grep -v node_modules | head -20
</parameter>

Repository: gridaco/grida

Length of output: 320


🏁 Script executed:

#!/bin/bash
# Check if there's any rate limiting middleware or request signing on the hook
echo "=== Check for middleware or auth on the hook route ==="
ls -la editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/

echo ""
echo "=== Look for any signing or secret verification in route ==="
cat editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts | head -150
</parameter>

Repository: gridaco/grida

Length of output: 4242


🏁 Script executed:

#!/bin/bash
# Verify if the endpoint is truly publicly accessible with no auth
echo "=== Check if there's a middleware.ts that might protect the route ==="
find editor/app/\(api\)/\(public\)/v1/submit -name "middleware.ts" 2>/dev/null

echo ""
echo "=== Check main middleware.ts for route protection ==="
rg -n "notification-respondent-email\|/v1/submit.*hooks" editor/middleware.ts 2>/dev/null || echo "Not in main middleware"

echo ""
echo "=== Look for any rate limiting library imports in route ==="
rg -n "rate|limit|throttle|ratelimit" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts
</parameter>

Repository: gridaco/grida

Length of output: 390


🏁 Script executed:

#!/bin/bash
# Check if there's request signing or HMAC verification anywhere
echo "=== Search for HMAC, signature, or secret verification in the codebase ==="
rg -n "hmac|signature|crypto.*sign|verify.*sign" -g '*.ts' editor/app/\(api\)/\(public\)/v1/submit --max-count=10
</parameter>

Repository: gridaco/grida

Length of output: 254


🏁 Script executed:

#!/bin/bash
# Check if the endpoint requires a secret/token header
echo "=== Check all imports and dependencies in the route ==="
head -10 editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts

echo ""
echo "=== Look for environment variables or secrets in the route file ==="
rg -n "process.env\|SECRET\|TOKEN\|AUTH" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts

Repository: gridaco/grida

Length of output: 549


🏁 Script executed:

#!/bin/bash
# Verify the form_id parameter comes from URL (not from request body)
echo "=== Confirm form_id source in route ==="
rg -B 2 -A 2 "context.params\|form_id" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts | head -20

Repository: gridaco/grida

Length of output: 96


Public hook can be abused to resend notifications repeatedly.

This endpoint is under a public submit path with no auth, signing, rate limiting, or idempotency tracking. The response_id is exposed in the form completion URL (as a query parameter), making it discoverable. Anyone with the form_id and response_id can replay requests to re-trigger emails indefinitely. While the endpoint validates that the response belongs to the given form, there is no mechanism to prevent duplicate sends for the same response.

Add an idempotency key (one per response), track notification_sent_at to skip re-sends, or implement rate limiting to mitigate replay attacks.

🤖 Prompt for AI Agents
In
`@editor/app/`(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts
around lines 11 - 134, The endpoint allows replay sends because it never records
or checks whether a notification was already sent for a response; modify the
POST handler (function POST) to enforce idempotency by checking a
notification_sent_at column on the response row before sending and atomically
setting it when you send: 1) query the response
(service_role.forms.from("response") select id, notification_sent_at) and if
notification_sent_at exists return skipped; 2) when ready to send, perform an
atomic update that sets notification_sent_at = now() only if it is currently
null (use a conditional update or a DB transaction/row lock) and fail/skip if
the update affected no rows (indicating another request already sent); 3) only
call resend.emails.send after successfully claiming the send (or, if you must
send before updating, update immediately after and handle failures by clearing
or compensating); ensure all checks reference response.id/response_id and the
new notification_sent_at field so duplicate POSTs are skipped.

}
Loading