# Backblaze B2 Native API v3 Reference

Verified spec for the methods used by this skill, against the live ACG B2
account (accountId `46f69bc61163`, region `us-west-001`). All facts below were
confirmed against the live API.

---

## Authorization (v3)

- **Authorize URL (fixed, global):**
  `https://api.backblazeb2.com/b2api/v3/b2_authorize_account`
- **Auth:** HTTP Basic. Username = key id, password = application key.
- **Method:** GET in the B2 docs; this skill issues it as an HTTPS POST with the
  Basic header, which the endpoint accepts.

### Authorize response (v3 shape)

```json
{
  "accountId": "46f69bc61163",
  "authorizationToken": "<token, valid 24h>",
  "apiInfo": {
    "storageApi": {
      "apiUrl": "https://api001.backblazeb2.com",
      "s3ApiUrl": "https://s3.us-west-001.backblazeb2.com",
      "downloadUrl": "https://f001.backblazeb2.com",
      "recommendedPartSize": 100000000,
      "absoluteMinimumPartSize": 5000000,
      "capabilities": ["listBuckets", "listFiles", "..."],
      "bucketId": null,
      "namePrefix": null
    }
  }
}
```

- **v3 nesting:** `apiUrl` / `s3ApiUrl` / `downloadUrl` / `capabilities` /
  `bucketId` / `namePrefix` live under `apiInfo.storageApi`. (v2 returned
  `apiUrl` / `downloadUrl` at the top level — do NOT parse them there for v3.)
- `bucketId == null` and `namePrefix == null` means an **account-wide** key;
  non-null means the key is scoped to one bucket / name prefix.
- The per-account `apiUrl` is dynamic and comes ONLY from this response — never
  from a config file.

### Cache model

The skill caches `authorizationToken` + `apiUrl` + `accountId` (plus the other
fields) in `.claude/skills/b2/.cache/auth.json` with an `authorized_at`
timestamp. It is treated as valid for ~23h (B2 tokens last 24h). A `401` with
code `expired_auth_token` or `bad_auth_token` triggers exactly one re-authorize
+ retry. **The cached token is a secret; the cache dir is gitignored.**

---

## Subsequent calls

- **URL:** `POST <apiUrl>/b2api/v3/<method>`
- **Header:** `Authorization: <authorizationToken>` (raw token, not Basic).
- **Body:** a JSON object.
- **Content-Type:** `application/json`.

### Error shape

B2 returns HTTP 4xx with a JSON body:

```json
{ "status": 400, "code": "<string>", "message": "<text>" }
```

The skill surfaces `status` / `code` / `message` verbatim. On `401` with
`expired_auth_token` / `bad_auth_token` it re-authorizes once and retries; all
other errors raise. Destructive calls are never retried automatically.

---

## Methods used

All POST to `<apiUrl>/b2api/v3/<method>`.

### b2_list_buckets

- **Body:** `{"accountId": "<acct>", "bucketId": <opt>}`
- **Returns:** `{"buckets": [{bucketName, bucketId, bucketType, revision,
  fileLockConfiguration:{isFileLockEnabled}, lifecycleRules, options}]}`
- Pass `bucketId` to scope the result to a single bucket.
- **`revision`** (int) is the bucket's optimistic-concurrency version. NOTE:
  this account's `b2_update_bucket` does NOT accept `ifRevisionMatch`, so this
  value is informational only — it is read but never sent back on a write.
- **`lifecycleRules`** (array) is the bucket's current lifecycle rule set (see
  the rule shape under `b2_update_bucket`). An empty array means no rules.

### b2_list_keys

- **Body:** `{"accountId": "<acct>", "maxKeyCount": 1000,
  "startApplicationKeyId": <opt>}`
- **Returns:** `{"keys": [{keyName, applicationKeyId, capabilities:[...],
  bucketId, namePrefix, expirationTimestamp}], "nextApplicationKeyId": <null
  when done>}`
- Paginate on `nextApplicationKeyId`.

### b2_list_file_names

- **Body:** `{"bucketId": "<id>", "maxFileCount": 10000, "prefix": <opt>,
  "startFileName": <opt>}`
- **Returns:** `{"files": [{fileName, contentLength, action, uploadTimestamp,
  ...}], "nextFileName": <null when done>}`
- Lists the **latest** name for each file. Use for quick listings; NOT for
  size/cost.

### b2_list_file_versions

- **Body:** `{"bucketId": "<id>", "maxFileCount": 10000, "prefix": <opt>,
  "startFileName": <opt>, "startFileId": <opt>}`
- **Returns:** `{"files": [{fileName, fileId, contentLength, action, ...}],
  "nextFileName": <null when done>, "nextFileId": ...}`
- **Pagination:** carry BOTH `nextFileName` AND `nextFileId` into the next
  request; stop when `nextFileName` is null.

#### action types

| action | meaning | billed? |
|---|---|---|
| `upload` | a real stored object | YES |
| `hide` | a hide marker (`contentLength` 0) | no |
| `start` | an unfinished large file | no |
| `folder` | a virtual folder placeholder | no |

**For size/cost, sum `contentLength` over ALL versions where
`action == "upload"`.** B2 bills every stored version, not just the latest, so
size MUST use `b2_list_file_versions`, never `b2_list_file_names`.

### b2_create_bucket (destructive — gated)

- **Body:** `{"accountId": "<acct>", "bucketName": "<name>", "bucketType":
  "allPrivate"}`
- `bucketType` defaults to `allPrivate`; the CLI allows `allPublic` via `--type`.

### b2_create_key (destructive — gated)

- **Body:** `{"accountId": "<acct>", "keyName": "<name>",
  "capabilities": [...], "bucketId": <opt>, "namePrefix": <opt>,
  "validDurationInSeconds": <opt>}`
- **Returns:** `{applicationKeyId, applicationKey, keyName, capabilities,
  bucketId, namePrefix}`.
- **`applicationKey` is the SECRET and is shown ONCE on creation** — it cannot be
  retrieved later. The CLI prints it with a prominent warning to store it in the
  vault immediately.
- `bucketId` scopes the key to one bucket (the CLI resolves a bucket name to its
  id via `b2_list_buckets`).

### b2_delete_bucket (destructive — gated)

- **Body:** `{"accountId": "<acct>", "bucketId": "<id>"}`
- B2 **refuses to delete a non-empty bucket**; that error is surfaced verbatim.

### b2_delete_key (destructive — gated)

- **Body:** `{"applicationKeyId": "<id>"}`

### b2_update_bucket (destructive — gated)

Used for the prefix-purge feature: it writes the bucket's lifecycle rules.

- **Body:** `{"accountId": "<acct>", "bucketId": "<id>",
  "lifecycleRules": [...]}`
- **Returns:** the updated bucket object (same shape as a `b2_list_buckets`
  entry, including the NEW `revision` and the written `lifecycleRules`).
- **`lifecycleRules` REPLACES the entire array — it is NOT additive (caveat).**
  To add/remove a single rule you MUST first read the current rules via
  `b2_list_buckets` (scoped to the bucketId), merge your change into the full
  array, then write the complete set back. Writing just the one rule you want
  silently drops every other rule.
- **No `ifRevisionMatch` on this account/endpoint.** This B2 account's
  `b2_update_bucket` REJECTS `ifRevisionMatch` with **HTTP 400** `bad_request`
  ("unknown field ... ifRevisionMatch"), so no optimistic-lock token is sent.
  Writes are therefore **last-write-wins** — the read-merge-write of the full
  rules array on every change is what keeps pre-existing rules intact. The skill
  does not implement a 409-conflict retry (there is no revision guard); it makes
  at most one defensive retry on a transient 5xx, never loops.
- B2 allows up to **100** lifecycle rules per bucket.

#### Lifecycle rule shape

```json
{ "fileNamePrefix": "MBS-<guid>/CBB_<machine>/",
  "daysFromUploadingToHiding": 1,
  "daysFromHidingToDeleting": 1 }
```

- `fileNamePrefix` — files whose name starts with this string are governed by the
  rule. `""` means the WHOLE BUCKET.
- `daysFromUploadingToHiding` — after this many days, B2's daily lifecycle pass
  HIDES the file (creates a hide marker).
- `daysFromHidingToDeleting` — after this many days hidden, B2 DELETES the file
  version permanently.
- With both `= 1`, every version older than ~1 day is hidden then deleted on the
  next daily passes -> full server-side purge of the prefix within ~24-48h. This
  is the mechanism the skill uses to purge prefixes with 1.2M+ versions, where
  per-file `b2_delete_file_version` would be impractical. **Irreversible — no
  recycle bin.**

### b2_get_file_info / b2_delete_file_version

- `b2_get_file_info` body: `{"fileId": "<id>"}` (read).
- `b2_delete_file_version` body: `{"fileName": "<name>", "fileId": "<id>"}`
  (destructive; exposed only via `raw --confirm` or the client helper).

---

## Cost formula

```
GB   = bytes / 1_000_000_000          # decimal GB (billing unit, NOT 2^30)
cost = GB * RATE_PER_GB_USD           # RATE_PER_GB_USD = 0.00695 (ACG cost basis)
```

`RATE_PER_GB_USD` is ACG's cost basis, recorded in
`.claude/memory/reference_backblaze_storage_rate.md`, and used by the GuruRMM
mspbackups storage-cost calc. Override with `--rate`. The `usage` report sums
upload-version bytes per bucket, converts to decimal GB, multiplies by the rate,
sorts by cost desc, and prints a TOTAL row plus grand totals. For `ACG-*`
buckets it derives a client label by stripping the `ACG-` prefix.

---

## Account structure (live)

- accountId `46f69bc61163`, region `us-west-001`.
- 12 buckets (all `allPrivate`): ACG-BST, ACG-Brett, ACG-Dataforth, ACG-GLAZTECH,
  ACG-IX, ACG-Internal, ACG-Lens, ACG-PST, ACG-REDNOUR, Horseshoe,
  MSPBackups20200311, VWP-Backup. Most hold MSP360/CloudBerry data
  (`MBS-<guid>/CBB_<CLIENT>/...`).
- 2 application keys: `cloudberrykey` (`00146f69bc611630000000003`, the
  MSP360/CloudBerry key) and `ClaudeTools` (`00146f69bc611630000000009`, used by
  this skill).
