# Uploads (/api/uploads)



## Overview [#overview]

When a user answers an upload question on a ticket form or an application, Tickety stores that
image privately. It is not publicly readable, and there is no permanent link to it.

Instead, HTTP events that contain an upload answer include an `upload` object with a URL you can
call using your existing HTTP events token. That call returns the image.

<Callout type="info">
  This applies to ticket form questions with the **Image Upload** style, and to application
  questions of type **UPLOAD**.
</Callout>

## Endpoint [#endpoint]

```
GET https://tickety.top/api/uploads/{scope}/{id}/{filename}
```

<TypeTable
  type="{
  scope: {
    description: 'Which kind of submission the upload belongs to',
    type: '&#x22;tickets&#x22; | &#x22;applications&#x22;',
  },
  id: {
    description: 'The ticketId or applicationId the upload was submitted with',
    type: 'string',
  },
  filename: {
    description: 'The stored filename, taken from the upload object in the event payload',
    type: 'string',
  },
}"
/>

You never need to construct this URL yourself. Every event that carries an upload gives you the
full URL in `upload.url`.

### Authentication [#authentication]

Send your HTTP events token in the `Authorization` header. This is the same token your webhook
endpoint receives, found under **Advanced Settings** in your dashboard settings.

```
Authorization: your-token-from-dashboard
```

A `Bearer your-token-from-dashboard` form is accepted too.

Your server is identified by the token alone. Nothing in the URL names a server, so a token can
only ever reach uploads belonging to the server it was issued for.

### Response [#response]

By default the endpoint responds with a `302` redirect to a short-lived link to the image. Most
HTTP clients follow this automatically, so you get the image bytes back.

```ts
const response = await fetch(upload.url, {
  headers: { Authorization: process.env.TICKETY_TOKEN },
});

const imageBuffer = Buffer.from(await response.arrayBuffer());
```

Pass `?format=json` if you would rather have the link itself, for example to hand it to another
service:

```ts
const response = await fetch(`${upload.url}?format=json`, {
  headers: { Authorization: process.env.TICKETY_TOKEN },
});

const { url } = await response.json();
```

```json
{
  "scope": "applications",
  "id": "APP-5678",
  "filename": "a1b2c3d4.webp",
  "url": "https://..."
}
```

<Callout type="warn">
  The returned link expires after a few minutes and is meant to be used immediately. Do not store
  it or embed it in a page. Store the `upload` object instead and request a fresh link whenever
  you need the image again.
</Callout>

### Status codes [#status-codes]

| Status | Meaning                                         |
| ------ | ----------------------------------------------- |
| `302`  | Redirect to the image. This is the success case |
| `401`  | Missing or invalid `Authorization` token        |
| `404`  | No such upload for your server                  |
| `429`  | Rate limited                                    |

`404` is returned for anything that is not yours, so the endpoint never reveals whether some
other server's ticket or file exists.

## Upload object [#upload-object]

Events that include an upload answer carry this object on the question:

<TypeTable
  type="{
  scope: {
    description: 'Which kind of submission the upload belongs to',
    type: '&#x22;tickets&#x22; | &#x22;applications&#x22;',
  },
  id: {
    description: 'The ticketId or applicationId the upload was submitted with',
    type: 'string',
  },
  filename: {
    description: 'The stored filename',
    type: 'string',
  },
  url: {
    description: 'Call this with your token to fetch the image',
    type: 'string',
  },
}"
/>

For an upload question, `answer` is set to the same URL as `upload.url` so that a consumer which
only reads `answer` still gets something usable. `answerRaw` is `null`, because there is no raw
text answer to report.

## Example [#example]

```ts
app.post("/tickety-webhook", async (req, res) => {
  const { type, payload } = req.body;

  if (type === "application.submit") {
    for (const question of payload.questions) {
      if (!question.upload) continue;

      const response = await fetch(question.upload.url, {
        headers: { Authorization: process.env.TICKETY_TOKEN },
      });

      if (!response.ok) {
        console.error(`Could not fetch ${question.upload.filename}`);
        continue;
      }

      await saveAttachment({
        applicationId: payload.applicationId,
        filename: question.upload.filename,
        body: Buffer.from(await response.arrayBuffer()),
      });
    }
  }

  res.status(200).send("OK");
});
```

## Rate limits [#rate-limits]

| Scope      | Limit                        |
| ---------- | ---------------------------- |
| Per IP     | 15 requests every 10 seconds |
| Per server | 60 requests every 60 seconds |

Fetch uploads as you process each event rather than in one large batch. A single event carries at
most a few uploads, so these limits are well clear of normal use. If you are backfilling, spread
the requests out and handle `429` by retrying later.
