# Create Linear or GitHub issues from form submissions

> Open a Linear issue or a GitHub issue for every bug report or feature request sent through an Ikiform form, with a relay that verifies the signature.

Published 2026-10-23 · Guide, Linear, GitHub · https://ikiform.com/blog/form-submissions-to-linear-and-github-issues

A bug report form is more useful when each report lands where the work happens. This guide turns each Ikiform submission into an issue, in Linear or in a GitHub repository. Pick one; the steps are the same apart from the API call.

Diagram: How a submission gets from your form to Linear or GitHub

1. A visitor submits your form, and it posts to Ikiform.
2. Ikiform stores the submission and sends a signed webhook to your relay.
3. The relay checks the signature, builds an issue title and body and creates the issue with the tracker's API.
4. Linear or GitHub answers the relay, and the relay answers Ikiform.

The relay checks Ikiform's signature, then creates an issue with the tracker's API.

## Why there's a relay

Linear and GitHub create issues through authenticated API calls, each with its own body. Ikiform can't add an API key or reshape its body, so a relay makes the call. Your API key stays in the relay, never in Ikiform.

## Before you start

- An Ikiform form that already receives submissions. A `subject` field makes good issue titles; without one, the relay uses `name`, then the submission id.
- For Linear, a workspace where you can create API keys. For GitHub, a repository where you can create issues.
- Somewhere to run the relay: a Cloudflare account with a domain on it, or a Next.js app on Vercel or any other host.

## What Ikiform sends

Every delivery is a `POST` with a JSON body. The body has the same shape for every endpoint, and you can't change it. A submission looks like this:

`submission.created`:

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-23T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-23T12:00:00.000Z",
    "truncated": false
  }
}
```

The form's values are in `data.fields`, keyed by the input `name`. A test from the dashboard sends an `endpoint.test` event instead, with `data.endpoint_id` and `data.message` and no fields. The full reference is on [Webhooks](https://ikiform.com/docs/webhooks.md#payloads).

## What the issue looks like

The title is the form name and the `subject` field, like `Bug report: Export button does nothing`. The body has one section per field. Each value sits in a code block, so a visitor can't @-mention your team, add images or break the layout:

Diagram: How the relay maps an Ikiform submission to issue

1. form.name + fields.subject becomes title: Bug report: Export button does nothing.
2. fields.name becomes body: name, value in a code block.
3. fields.email becomes body: email, value in a code block.
4. fields.message becomes body: message, value in a code block.
5. submission_id + delivery id becomes body: last line with both ids.

No subject field? The title falls back to name, then the submission id.

`Issue body`:

````text
**name**

```text
Ada
```

**email**

```text
ada@example.com
```

**message**

```text
Hello
```

Ikiform submission `sub_01k7v2c9t3e8m5q4x6z0a1b2c3`, delivery `dlv_01k7v2d4n8r2p6s0w3y5b7c9e1`
````

## Linear

### Step 1: Create an API key

In Linear, open [Settings → Security & access](https://linear.app/settings/account/security) and create a personal API key. Linear expects it in the `Authorization` header as is, without `Bearer`.

### Step 2: Find the team ID

`Terminal`:

```bash
curl https://api.linear.app/graphql \
  -H "Authorization: $LINEAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ teams { nodes { id name } } }"}'
```

`Response`:

```json
{
  "data": {
    "teams": {
      "nodes": [{ "id": "9cfb482a-0000-0000-0000-000000000000", "name": "Support" }]
    }
  }
}
```

### Step 3: Write the relay

**Cloudflare Worker** (`src/index.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  LINEAR_API_KEY: string;
  LINEAR_TEAM_ID: string;
}

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

const TITLE_FIELD = "subject";

function fence(text: string) {
  const longest = Math.max(2, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
  const marker = "`".repeat(longest + 1);
  return `${marker}text\n${text}\n${marker}`;
}

function issueContent(event: Extract<WebhookEvent, { type: "submission.created" }>) {
  const { form, fields, submission_id, truncated } = event.data;
  const subject = fields[TITLE_FIELD] ?? fields.name ?? submission_id;
  const title = `${form.name}: ${valueText(subject)}`.replace(/\s+/g, " ").slice(0, 200);
  const sections: string[] = [];
  let length = 0;
  for (const [name, value] of Object.entries(fields)) {
    const section = `**${name.replace(/[^\w .-]/g, "")}**\n\n${fence(valueText(value))}`;
    length += section.length + 2;
    if (length > 60000) {
      sections.push("More fields didn't fit. Open the submission in Ikiform.");
      break;
    }
    sections.push(section);
  }
  if (truncated) sections.push("The submission was too large to include. Open it in Ikiform.");
  sections.push(`Ikiform submission \`${submission_id}\`, delivery \`${event.id}\``);
  return { title, body: sections.join("\n\n") };
}

const ISSUE_CREATE = `mutation IssueCreate($input: IssueCreateInput!) {
  issueCreate(input: $input) {
    success
    issue { identifier url }
  }
}`;

interface LinearResult {
  data?: { issueCreate?: { success: boolean } };
  errors?: { message: string; extensions?: { code?: string } }[];
}

async function createIssue(event: WebhookEvent, apiKey: string, teamId: string) {
  if (event.type !== "submission.created") return new Response(null, { status: 204 });
  const { title, body } = issueContent(event);
  const response = await fetch("https://api.linear.app/graphql", {
    method: "POST",
    headers: { authorization: apiKey, "content-type": "application/json" },
    body: JSON.stringify({ query: ISSUE_CREATE, variables: { input: { teamId, title, description: body } } }),
  });
  const result = (await response.json().catch(() => null)) as LinearResult | null;
  if (result?.errors?.some((error) => error.extensions?.code === "RATELIMITED")) {
    return new Response(null, { status: 429, headers: { "retry-after": "60" } });
  }
  if (!response.ok) return new Response(null, { status: response.status, headers: response.headers });
  if (!result?.data?.issueCreate?.success) return new Response(null, { status: 500 });
  return new Response(null, { status: 201 });
}

export default {
  async fetch(request: Request, env: Env) {
    if (request.method !== "POST") return new Response(null, { status: 405 });
    const payload = await request.text();
    let event: WebhookEvent;
    try {
      event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
      throw error;
    }
    const response = await createIssue(event, env.LINEAR_API_KEY, env.LINEAR_TEAM_ID);
    if (response.status === 429) {
      const retryAfter = response.headers.get("retry-after") ?? "30";
      return new Response("Linear rate limit", { status: 503, headers: { "retry-after": retryAfter } });
    }
    if (!response.ok) return new Response(`Linear answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  },
};
```

**Next.js** (`app/api/ikiform/linear/route.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

const TITLE_FIELD = "subject";

function fence(text: string) {
  const longest = Math.max(2, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
  const marker = "`".repeat(longest + 1);
  return `${marker}text\n${text}\n${marker}`;
}

function issueContent(event: Extract<WebhookEvent, { type: "submission.created" }>) {
  const { form, fields, submission_id, truncated } = event.data;
  const subject = fields[TITLE_FIELD] ?? fields.name ?? submission_id;
  const title = `${form.name}: ${valueText(subject)}`.replace(/\s+/g, " ").slice(0, 200);
  const sections: string[] = [];
  let length = 0;
  for (const [name, value] of Object.entries(fields)) {
    const section = `**${name.replace(/[^\w .-]/g, "")}**\n\n${fence(valueText(value))}`;
    length += section.length + 2;
    if (length > 60000) {
      sections.push("More fields didn't fit. Open the submission in Ikiform.");
      break;
    }
    sections.push(section);
  }
  if (truncated) sections.push("The submission was too large to include. Open it in Ikiform.");
  sections.push(`Ikiform submission \`${submission_id}\`, delivery \`${event.id}\``);
  return { title, body: sections.join("\n\n") };
}

const ISSUE_CREATE = `mutation IssueCreate($input: IssueCreateInput!) {
  issueCreate(input: $input) {
    success
    issue { identifier url }
  }
}`;

interface LinearResult {
  data?: { issueCreate?: { success: boolean } };
  errors?: { message: string; extensions?: { code?: string } }[];
}

async function createIssue(event: WebhookEvent, apiKey: string, teamId: string) {
  if (event.type !== "submission.created") return new Response(null, { status: 204 });
  const { title, body } = issueContent(event);
  const response = await fetch("https://api.linear.app/graphql", {
    method: "POST",
    headers: { authorization: apiKey, "content-type": "application/json" },
    body: JSON.stringify({ query: ISSUE_CREATE, variables: { input: { teamId, title, description: body } } }),
  });
  const result = (await response.json().catch(() => null)) as LinearResult | null;
  if (result?.errors?.some((error) => error.extensions?.code === "RATELIMITED")) {
    return new Response(null, { status: 429, headers: { "retry-after": "60" } });
  }
  if (!response.ok) return new Response(null, { status: response.status, headers: response.headers });
  if (!result?.data?.issueCreate?.success) return new Response(null, { status: 500 });
  return new Response(null, { status: 201 });
}

export async function POST(request: Request) {
  const payload = await request.text();
  let event: WebhookEvent;
  try {
    event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  const response = await createIssue(event, process.env.LINEAR_API_KEY!, process.env.LINEAR_TEAM_ID!);
  if (response.status === 429) {
    const retryAfter = response.headers.get("retry-after") ?? "30";
    return new Response("Linear rate limit", { status: 503, headers: { "retry-after": retryAfter } });
  }
  if (!response.ok) return new Response(`Linear answered ${response.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

- Linear reports rate limits as HTTP `400` with `RATELIMITED` in `errors[].extensions.code`, not as `429`. The relay turns that into a `503` with `Retry-After: 60`, so Ikiform waits and tries again.
- GraphQL can answer `200` with errors. The relay only answers `2xx` when `issueCreate.success` is `true`.
- `endpoint.test` events return `204` and create nothing.

### Step 4: Deploy it

`Terminal`:

```bash
mkdir ikiform-issues && cd ikiform-issues
npm init -y
npm install @ikiform/sdk
npm install -D wrangler
```

Save the Worker code as `src/index.ts`, then set the route in `wrangler.jsonc`:

`wrangler.jsonc`:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "ikiform-issues",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "routes": [{ "pattern": "hooks.example.com", "custom_domain": true }]
}
```

> Ikiform refuses `workers.dev` URLs, so the Worker needs a hostname on a domain you have on Cloudflare, like `hooks.example.com`. The `custom_domain` route creates the DNS record and certificate for you on deploy.

`Terminal`:

```bash
npx wrangler secret put IKIFORM_WEBHOOK_SECRET
npx wrangler secret put LINEAR_API_KEY
npx wrangler secret put LINEAR_TEAM_ID
npx wrangler deploy
```

On Next.js, add `@ikiform/sdk`, save the route handler, and set `IKIFORM_WEBHOOK_SECRET`, `LINEAR_API_KEY` and `LINEAR_TEAM_ID` in your host's environment variables.

## GitHub Issues

### Step 1: Create a fine-grained token

1. In GitHub, open **Settings → Developer settings → Personal access tokens → Fine-grained tokens** and generate a new token.
2. Under repository access, pick only the repository that should get the issues.
3. Under repository permissions, set **Issues** to **Read and write**.

Set `GITHUB_REPO` to `owner/repo`, for example `acme/website`.

### Step 2: Write the relay

**Cloudflare Worker** (`src/index.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  GITHUB_TOKEN: string;
  GITHUB_REPO: string;
}

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

const TITLE_FIELD = "subject";

function fence(text: string) {
  const longest = Math.max(2, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
  const marker = "`".repeat(longest + 1);
  return `${marker}text\n${text}\n${marker}`;
}

function issueContent(event: Extract<WebhookEvent, { type: "submission.created" }>) {
  const { form, fields, submission_id, truncated } = event.data;
  const subject = fields[TITLE_FIELD] ?? fields.name ?? submission_id;
  const title = `${form.name}: ${valueText(subject)}`.replace(/\s+/g, " ").slice(0, 200);
  const sections: string[] = [];
  let length = 0;
  for (const [name, value] of Object.entries(fields)) {
    const section = `**${name.replace(/[^\w .-]/g, "")}**\n\n${fence(valueText(value))}`;
    length += section.length + 2;
    if (length > 60000) {
      sections.push("More fields didn't fit. Open the submission in Ikiform.");
      break;
    }
    sections.push(section);
  }
  if (truncated) sections.push("The submission was too large to include. Open it in Ikiform.");
  sections.push(`Ikiform submission \`${submission_id}\`, delivery \`${event.id}\``);
  return { title, body: sections.join("\n\n") };
}

async function createIssue(event: WebhookEvent, token: string, repo: string) {
  if (event.type !== "submission.created") return new Response(null, { status: 204 });
  const { title, body } = issueContent(event);
  return fetch(`https://api.github.com/repos/${repo}/issues`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${token}`,
      accept: "application/vnd.github+json",
      "content-type": "application/json",
      "user-agent": "ikiform-issues-relay",
      "x-github-api-version": "2026-03-10",
    },
    body: JSON.stringify({ title, body }),
  });
}

export default {
  async fetch(request: Request, env: Env) {
    if (request.method !== "POST") return new Response(null, { status: 405 });
    const payload = await request.text();
    let event: WebhookEvent;
    try {
      event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
      throw error;
    }
    const response = await createIssue(event, env.GITHUB_TOKEN, env.GITHUB_REPO);
    if (response.status === 429) {
      const retryAfter = response.headers.get("retry-after") ?? "30";
      return new Response("GitHub rate limit", { status: 503, headers: { "retry-after": retryAfter } });
    }
    if (!response.ok) return new Response(`GitHub answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  },
};
```

**Next.js** (`app/api/ikiform/github/route.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

const TITLE_FIELD = "subject";

function fence(text: string) {
  const longest = Math.max(2, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
  const marker = "`".repeat(longest + 1);
  return `${marker}text\n${text}\n${marker}`;
}

function issueContent(event: Extract<WebhookEvent, { type: "submission.created" }>) {
  const { form, fields, submission_id, truncated } = event.data;
  const subject = fields[TITLE_FIELD] ?? fields.name ?? submission_id;
  const title = `${form.name}: ${valueText(subject)}`.replace(/\s+/g, " ").slice(0, 200);
  const sections: string[] = [];
  let length = 0;
  for (const [name, value] of Object.entries(fields)) {
    const section = `**${name.replace(/[^\w .-]/g, "")}**\n\n${fence(valueText(value))}`;
    length += section.length + 2;
    if (length > 60000) {
      sections.push("More fields didn't fit. Open the submission in Ikiform.");
      break;
    }
    sections.push(section);
  }
  if (truncated) sections.push("The submission was too large to include. Open it in Ikiform.");
  sections.push(`Ikiform submission \`${submission_id}\`, delivery \`${event.id}\``);
  return { title, body: sections.join("\n\n") };
}

async function createIssue(event: WebhookEvent, token: string, repo: string) {
  if (event.type !== "submission.created") return new Response(null, { status: 204 });
  const { title, body } = issueContent(event);
  return fetch(`https://api.github.com/repos/${repo}/issues`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${token}`,
      accept: "application/vnd.github+json",
      "content-type": "application/json",
      "user-agent": "ikiform-issues-relay",
      "x-github-api-version": "2026-03-10",
    },
    body: JSON.stringify({ title, body }),
  });
}

export async function POST(request: Request) {
  const payload = await request.text();
  let event: WebhookEvent;
  try {
    event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  const response = await createIssue(event, process.env.GITHUB_TOKEN!, process.env.GITHUB_REPO!);
  if (response.status === 429) {
    const retryAfter = response.headers.get("retry-after") ?? "30";
    return new Response("GitHub rate limit", { status: 503, headers: { "retry-after": retryAfter } });
  }
  if (!response.ok) return new Response(`GitHub answered ${response.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

- GitHub rejects requests without a `User-Agent` header, so the relay always sends one.
- A new issue answers `201`. A `410` means issues are turned off for the repository, and a `422` means GitHub refused the content.
- Labels and assignees need push access, and GitHub drops them silently without it. That's why this relay sends only a title and a body. Add `labels` once the token's account can push.

### Step 3: Deploy it

Use the same project setup as for Linear, with GitHub's secrets:

`Terminal`:

```bash
npx wrangler secret put IKIFORM_WEBHOOK_SECRET
npx wrangler secret put GITHUB_TOKEN
npx wrangler secret put GITHUB_REPO
npx wrangler deploy
```

On Next.js, set `IKIFORM_WEBHOOK_SECRET`, `GITHUB_TOKEN` and `GITHUB_REPO` in your host's environment variables.

## Connect Ikiform

### Step 1: Add the endpoint in Ikiform

- In the Ikiform dashboard, open **Webhooks** and choose **Add endpoint**.
- Paste your relay URL into **URL**.
- Under **Forms**, pick one form, or **All forms** in the workspace.
- Add a **Description** if you like, such as "Bug reports to Linear", then choose **Add endpoint**.
- Copy the signing secret. It starts with `whsec_` and is shown in full only once.

There's no event picker. An endpoint gets `submission.created` for every accepted submission on the forms you chose, and `endpoint.test` when you send a test. A workspace on Pro can have up to 3 endpoints.

### Step 2: Add the secret to the relay

Set `IKIFORM_WEBHOOK_SECRET` to the `whsec_` secret. On Cloudflare run `npx wrangler secret put IKIFORM_WEBHOOK_SECRET` again. On other hosts, update the variable and redeploy.

Ikiform only delivers to public `https://` URLs on port 443. IP addresses, `localhost`, internal hostnames, `workers.dev` and `pages.dev` are refused when you save the endpoint.

## Test it

Open the endpoint in Ikiform and choose **Send test**. Ikiform sends one `endpoint.test` event with no retries, and the result shows up in the endpoint's delivery log within a few seconds. Then send a real submission from your form, or from a terminal:

`Terminal`:

```bash
curl https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada","email":"ada@example.com","message":"Hello from curl"}'
```

A `201` with `"ok": true` means Ikiform stored it. The delivery appears on the endpoint page, and the message lands in Linear or GitHub as a new issue.

> If the form has a human check or allowed domains turned on, a post from curl is refused or marked as spam, and spam never sends a webhook. Test from your real site instead.

The test event returns `204` from the relay and creates nothing.

## How delivery works

| The receiver answers | Ikiform |
| --- | --- |
| Any `2xx` within 15 seconds | Marks the delivery succeeded |
| A redirect | Doesn't follow it, and counts a failure |
| `429` or `503` with `Retry-After` | Waits that long, up to the next step of the schedule |
| `410 Gone` | Fails the delivery and disables the endpoint |
| Anything else, or no answer in 15 seconds | Retries |

Failed deliveries are retried 8 times over about 3 d 6 h, with the same `webhook-id` each time. If every delivery to an endpoint fails for 5 days, Ikiform disables it and emails the workspace owners.

Diagram: What the relay tells Ikiform when Linear answers

1. Linear accepts the request: the relay answers 204, and Ikiform marks the delivery succeeded.
2. Linear is rate limited (400): the relay answers 503 with Retry-After (retry-after: 60), and Ikiform waits that long before it tries again.
3. Linear answers with any other error: the relay answers 502, and Ikiform retries, 8 retries over about 3 d 6 h.

Linear reports a rate limit as a 400 with RATELIMITED in the errors. The relay answers 503 with Retry-After: 60. For GitHub, a 429 becomes a 503 and any other error a 502, the same way.

### Duplicate issues

Ikiform retries when the relay doesn't answer `2xx` in time. If the tracker created the issue but answered slowly, the retry creates a second one. It's rare, and every issue ends with the delivery id, so duplicates are easy to spot and close.

> For strict once-only, store each `webhook-id` before calling the API, for example in Workers KV, and answer `204` straight away when you've seen it. [Receiving webhooks](https://ikiform.com/docs/guides/webhook-handlers.md#duplicates) shows the pattern.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| `400 no_matching_signature` | The secret is wrong, or the body was parsed and re-serialized before verifying. Use the `whsec_` secret of this endpoint and the raw body exactly as received. After **Roll secret**, update it within 24 hours. |
| `502` with `Linear answered 400` | The API key or team ID is wrong. Personal keys go in `Authorization` without `Bearer`. Run the teams query above with the same key. |
| `502` with `Linear answered 500` | Linear answered but `issueCreate.success` wasn't `true`. Log `result.errors` in the relay to see why. |
| `502` with `GitHub answered 403` or `404` | The token can't write issues on this repository, or `GITHUB_REPO` is misspelled. Fine-grained tokens only see the repositories you picked. |
| `502` with `GitHub answered 410` | Issues are turned off in the repository's settings. |
| Delivery log shows `timeout` | Nothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away. |
| Endpoint is disabled | Every delivery failed for 5 days, the URL answered `410`, or its hostname started resolving to a private address. Fix the cause, then turn **Enabled** back on and **Retry now** the failed deliveries. |
| No delivery at all | Check that the endpoint covers this form, that it's enabled, and that the submission wasn't marked as spam or held because the workspace is over its monthly limit. |

## Next steps

- Add a label or a priority: Linear's `issueCreate` takes `labelIds` and `priority`, and GitHub takes `labels`.
- Send bug reports and feature requests to different teams with one endpoint per form.
- Tell the team in chat as well: see [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md).

| Reference | Link |
| --- | --- |
| Linear GraphQL API | [linear.app/developers](https://linear.app/developers/graphql) |
| Linear rate limits | [linear.app/developers](https://linear.app/developers/rate-limiting) |
| GitHub: create an issue | [docs.github.com](https://docs.github.com/en/rest/issues/issues#create-an-issue) |
| GitHub: token permissions | [docs.github.com](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens) |
| GitHub: API versions | [docs.github.com](https://docs.github.com/en/rest/about-the-rest-api/api-versions) |
