# Upload a campaign's contract

`POST https://api.tryscouty.com/v1/campaigns/{campaignId}/contract`

Uploads the contract creators sign for one campaign, replacing the one it has. Only a draft or paused campaign takes one, as in the dashboard. contractBase64 is the PDF as standard base64, at most 4,300,000 characters (a PDF of about 3.2MB). The dashboard takes a contract up to 4MB; upload a larger one there.

- Scope: `campaigns:write`
- Kind: write
- Safe to retry with the same idempotency key: yes
- MCP tool: `scouty_upload_campaign_contract`

## Authorization

Send `Authorization: Bearer $SCOUTY_TOKEN`. The token needs the `campaigns:write` scope.

## Path parameters

| Name | Type | Required | Description |
|---|---|---|---|
| `campaignId` | `integer` | Required | The campaign the contract is for. |

## Headers

| Name | Type | Required | Description |
|---|---|---|---|
| `Idempotency-Key` | `string` | Optional | The same key the body's idempotencyKey carries. Send one or the other, or both with the same value. |

## Body parameters

| Name | Type | Required | Description |
|---|---|---|---|
| `idempotencyKey` | `string` | Required | A key you choose for this call, 8 to 128 characters of letters, digits, dot, underscore, colon or hyphen. Sending the same key with the same arguments replays the first answer and changes nothing, so a retry after a timeout is safe. |
| `contractBase64` | `string` | Required | The contract, a PDF, as standard base64 with no data: prefix. |

## Details

`scouty_upload_campaign_contract` does what the contract field on a campaign does in your dashboard. It replaces the campaign's contract, and only while the campaign is a draft or paused. It does not fill the campaign in from the contract, and it does not change whether creators have to sign it.

The file goes in the JSON body as `contractBase64`: the PDF as standard base64, with no line breaks and no `data:` prefix. The field takes at most 4,300,000 characters, which is a PDF of about 3.2 MB. The dashboard takes a contract up to 4 MB, so upload a larger one there. Scouty reads the type from the file itself, so a file that does not start with `%PDF-` is refused.

```json
{ "status": "uploaded", "campaignId": 212 }
```

On a live campaign:

```json
{ "status": "refused", "rule": "campaign_state",
  "message": "This campaign is published, so its details and terms are locked. Ask Scout to pause it and they open again." }
```

## Request

### cURL

```bash
printf '{"idempotencyKey":"contract-42-v2","contractBase64":"%s"}' \
  "$(base64 < contract.pdf | tr -d '\n')" > body.json
curl -s -X POST https://api.tryscouty.com/v1/campaigns/42/contract \
  -H "Authorization: Bearer $SCOUTY_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @body.json
```

### JavaScript

```js
import { readFileSync } from "node:fs";

const response = await fetch("https://api.tryscouty.com/v1/campaigns/42/contract", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SCOUTY_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    idempotencyKey: "contract-42-v2",
    contractBase64: readFileSync("contract.pdf").toString("base64")
  }),
});
console.log(await response.json());
```

### Python

```python
import base64
import os

import requests

token = os.environ["SCOUTY_TOKEN"]
response = requests.post(
    "https://api.tryscouty.com/v1/campaigns/42/contract",
    headers={"Authorization": f"Bearer {token}"},
    json={
        "idempotencyKey": "contract-42-v2",
        "contractBase64": base64.b64encode(open("contract.pdf", "rb").read()).decode(),
    },
)
print(response.json())
```

## Responses

### 200 OK

```json
{
  "status": "uploaded",
  "campaignId": 42
}
```

### 400 Bad Request

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Those arguments are not the shape this capability takes: idempotencyKey."
  }
}
```

### 401 Unauthorized

```json
{
  "error": {
    "code": "missing_token",
    "message": "This API needs an Authorization header carrying a bearer token."
  }
}
```

### 403 Forbidden

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "This token does not carry the campaigns:write scope. Ask the team for one that does."
  }
}
```

### 404 Not Found

```json
{
  "error": {
    "code": "not_found",
    "message": "No such campaign."
  }
}
```

### 409 Conflict

```json
{
  "error": {
    "code": "idempotency_conflict",
    "message": "That idempotency key was already used for a different request. Use a new key."
  }
}
```

### 429 Too Many Requests

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Please wait for the current minute to end."
  }
}
```

## Errors

| Status | `error.code` | Meaning |
|---|---|---|
| 400 Bad Request | `invalid_request` | The request was not the shape this capability takes, or its body was not JSON. |
| 401 Unauthorized | `missing_token`, `invalid_token`, `token_revoked`, `token_expired`, `token_orphaned` | No token, or one that is not recognized, revoked or expired. |
| 403 Forbidden | `insufficient_scope`, `wrong_principal` | The token does not carry the scope this capability needs. |
| 404 Not Found | `not_found` | No such route, or no such campaign for this client. |
| 409 Conflict | `idempotency_conflict` | That idempotency key was already used for a different request. Nothing was changed. |
| 429 Too Many Requests | `rate_limited` | Too many requests this minute. Retry-After says how long is left. |

Full reference: https://docs.tryscouty.com/reference
