# The SFTP file drop

> For a system that sends files rather than calls: an SFTP user under your own key, files dropped in one folder and answers read from another.

Page: https://payerworks.com/docs/sftp · Markdown: https://payerworks.com/docs/sftp.md

> Payerworks writes and explains files; it sends nothing to a payer. Every file a route writes says `transmitted: false`, and is yours to send on your own connection. A test key is for invented data only, never a real patient.

For a system that drops files rather than calling an API. Make an SFTP user under your key, add your SSH public key (there are no passwords), and drop 837P files in `/inbound`: the answers are written to `/outbound`. The drop is open only where Payerworks has turned it on; elsewhere every route answers `sftp_not_open` and every other API route still answers.

## Routes

- [GET /v1/sftp](#get-v1-sftp): Your SFTP user
- [PUT /v1/sftp](#put-v1-sftp): Make your SFTP user
- [DELETE /v1/sftp](#delete-v1-sftp): Remove your SFTP user
- [POST /v1/sftp/ssh-keys](#post-v1-sftp-ssh-keys): Add an SSH key
- [DELETE /v1/sftp/ssh-keys/{id}](#delete-v1-sftp-ssh-keys-id): Remove an SSH key

Each route shows its call with curl and with the two clients, then its real answer, shortened, and the fields it holds. The client calls assume one client made once, `const pw = new Payerworks()` in JavaScript or `pw = payerworks.Payerworks()` in Python, each reading PAYERWORKS_API_KEY and PAYERWORKS_BASE_URL, and `body` is the request.json shown.

### GET /v1/sftp

**Your SFTP user.** The host, your user name, your two folders (/inbound and /outbound), your SSH keys by fingerprint, when a 999 is written, and what is kept and for how long. A key with no user answers 404 sftp_user_not_found.

Request

```bash
curl -sS -X GET "$PAYERWORKS_BASE_URL/v1/sftp" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY"
```

With the JavaScript client

```js
const answer = await pw.getSftp();
```

With the Python client

```python
answer = pw.get_sftp()
```

The answer (200)

```json
{
  "user_name": "pwc-pwk_0123456789abcdef01",
  "host": "s-0123456789abcdef0.server.transfer.us-east-1.amazonaws.com",
  "port": 22,
  "mode": "test",
  "folders": {
    "inbound": "/inbound",
    "outbound": "/outbound"
  },
  "receiver": {
    "qualifier": "ZZ",
    "id": "PAYERWORKSTEST",
    "usage_indicator": "T"
  },
  "acknowledgment_999": "always",
  "reads": [
    "837P",
    "837I"
  ],
  "max_file_bytes": 2000000,
  "ssh_keys": [
    {
      "id": "key-0a1b2c3d4e5f6a7b1",
      "algorithm": "ssh-ed25519",
      "fingerprint": "SHA256:EN9Jp9JJbIabR3Qcsj0tqYdUVi1NxOEfJBNgjLzYedc",
      "added_at": "2026-10-19T15:00:00.000Z"
    }
  ],
  "created_at": "2026-10-19T15:00:00.000Z",
  "updated_at": "2026-10-19T15:00:00.000Z",
  "retention": [
    "A file you drop in inbound is read once and deleted as soon as its answers are written. A file the lane could not reach is deleted by the bucket 1 day after it arrived; S3 removes an expired file in the day or so after its expiry.",
    "Each answer (a TA1, a 999 and the report) waits in outbound for 7 days after it was written, then the bucket deletes it; you may delete it sooner. S3 removes an expired file in the day or so after its expiry."
  ],
  "transmits": false
}
```

Shortened here: lists show their first two items and long strings are cut. The whole answer is this route’s example in [/openapi.json](https://payerworks.com/openapi.json).

| Field | Type | What it is |
| --- | --- | --- |
| `user_name` | string |  |
| `host` | string |  |
| `port` | integer |  |
| `mode` | one of "test", "live" |  |
| `folders` | object |  |
| `receiver` | object | What a dropped file’s ISA07, ISA08 and ISA15 must say. |
| `acknowledgment_999` | one of "always", "errors_only", "never" |  |
| `reads` | list of string |  |
| `max_file_bytes` | integer |  |
| `ssh_keys` | list of SshKey | At most 10. |
| `created_at` | string |  |
| `updated_at` | string |  |
| `retention` | list of string | What is kept, and for how long. |
| `transmits` | the value false |  |

### PUT /v1/sftp

**Make your SFTP user.** Makes this key’s SFTP user, or changes when a 999 is written for a file you drop: acknowledgment_999 is always, errors_only (the default: only when a group or a set is not plainly accepted) or never. A test key’s user sends usage indicator T to receiver PAYERWORKSTEST; a live key’s sends P to PAYERWORKS (qualifier ZZ). Payerworks validates and acknowledges what you drop; nothing goes on to a payer.

Request

```bash
curl -sS -X PUT "$PAYERWORKS_BASE_URL/v1/sftp" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY" \
  -H "Content-Type: application/json" \
  -d @request.json
```

With the JavaScript client

```js
const answer = await pw.putSftp(body);
```

With the Python client

```python
answer = pw.put_sftp(body)
```

request.json

```json
{
  "acknowledgment_999": "always"
}
```

The answer (200)

```json
{
  "user_name": "pwc-pwk_0123456789abcdef01",
  "host": "s-0123456789abcdef0.server.transfer.us-east-1.amazonaws.com",
  "port": 22,
  "mode": "test",
  "folders": {
    "inbound": "/inbound",
    "outbound": "/outbound"
  },
  "receiver": {
    "qualifier": "ZZ",
    "id": "PAYERWORKSTEST",
    "usage_indicator": "T"
  },
  "acknowledgment_999": "always",
  "reads": [
    "837P",
    "837I"
  ],
  "max_file_bytes": 2000000,
  "ssh_keys": [],
  "created_at": "2026-10-19T15:00:00.000Z",
  "updated_at": "2026-10-19T15:00:00.000Z",
  "retention": [
    "A file you drop in inbound is read once and deleted as soon as its answers are written. A file the lane could not reach is deleted by the bucket 1 day after it arrived; S3 removes an expired file in the day or so after its expiry.",
    "Each answer (a TA1, a 999 and the report) waits in outbound for 7 days after it was written, then the bucket deletes it; you may delete it sooner. S3 removes an expired file in the day or so after its expiry."
  ],
  "transmits": false
}
```

Shortened here: lists show their first two items and long strings are cut. The whole answer is this route’s example in [/openapi.json](https://payerworks.com/openapi.json).

| Field | Type | What it is |
| --- | --- | --- |
| `user_name` | string |  |
| `host` | string |  |
| `port` | integer |  |
| `mode` | one of "test", "live" |  |
| `folders` | object |  |
| `receiver` | object | What a dropped file’s ISA07, ISA08 and ISA15 must say. |
| `acknowledgment_999` | one of "always", "errors_only", "never" |  |
| `reads` | list of string |  |
| `max_file_bytes` | integer |  |
| `ssh_keys` | list of SshKey | At most 10. |
| `created_at` | string |  |
| `updated_at` | string |  |
| `retention` | list of string | What is kept, and for how long. |
| `transmits` | the value false |  |

### DELETE /v1/sftp

**Remove your SFTP user.** The user can no longer sign in. Answers already in /outbound are deleted by the bucket 7 days after each was written.

Request

```bash
curl -sS -X DELETE "$PAYERWORKS_BASE_URL/v1/sftp" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY"
```

With the JavaScript client

```js
const answer = await pw.deleteSftp();
```

With the Python client

```python
answer = pw.delete_sftp()
```

The answer (200)

```json
{
  "deleted": true,
  "note": "The user can no longer sign in. Answers already in outbound are deleted by the bucket 7 days after each was written.",
  "retention": [
    "A file you drop in inbound is read once and deleted as soon as its answers are written. A file the lane could not reach is deleted by the bucket 1 day after it arrived; S3 removes an expired file in the day or so after its expiry.",
    "Each answer (a TA1, a 999 and the report) waits in outbound for 7 days after it was written, then the bucket deletes it; you may delete it sooner. S3 removes an expired file in the day or so after its expiry."
  ]
}
```

Shortened here: lists show their first two items and long strings are cut. The whole answer is this route’s example in [/openapi.json](https://payerworks.com/openapi.json).

| Field | Type | What it is |
| --- | --- | --- |
| `deleted` | the value true |  |
| `note` | string |  |
| `retention` | list of string |  |

### POST /v1/sftp/ssh-keys

**Add an SSH key.** One OpenSSH public key line (ssh-ed25519, ssh-rsa or ecdsa), as your .pub file holds it; at most 10. There are no passwords. A private key is refused and never kept; a key’s comment is dropped.

Request

```bash
curl -sS -X POST "$PAYERWORKS_BASE_URL/v1/sftp/ssh-keys" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY" \
  -H "Content-Type: application/json" \
  -d @request.json
```

With the JavaScript client

```js
const answer = await pw.postSftpSshKeys(body);
```

With the Python client

```python
answer = pw.post_sftp_ssh_keys(body)
```

request.json

```json
{
  "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINXvOd5VaJqJWXfe+7VV3zPAkJmYjT4WyPx9TO5a9G+H dev@example.com"
}
```

The answer (200)

```json
{
  "id": "key-0a1b2c3d4e5f6a7b1",
  "algorithm": "ssh-ed25519",
  "fingerprint": "SHA256:EN9Jp9JJbIabR3Qcsj0tqYdUVi1NxOEfJBNgjLzYedc",
  "added_at": "2026-10-19T15:00:00.000Z"
}
```

| Field | Type | What it is |
| --- | --- | --- |
| `id` | string | key- and 17 lowercase hexadecimal characters. |
| `algorithm` | string |  |
| `fingerprint` | string | SHA256:, as ssh-keygen -lf shows it. |
| `added_at` | string |  |

### DELETE /v1/sftp/ssh-keys/{id}

**Remove an SSH key.** That key can no longer sign in.

Request

```bash
curl -sS -X DELETE "$PAYERWORKS_BASE_URL/v1/sftp/ssh-keys/key-0a1b2c3d4e5f6a7b1" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY"
```

With the JavaScript client

```js
const answer = await pw.deleteSftpSshKeysById("key-0a1b2c3d4e5f6a7b1");
```

With the Python client

```python
answer = pw.delete_sftp_ssh_keys_by_id("key-0a1b2c3d4e5f6a7b1")
```

The answer (200)

```json
{
  "deleted": true,
  "id": "key-0a1b2c3d4e5f6a7b1"
}
```

| Field | Type | What it is |
| --- | --- | --- |
| `deleted` | the value true |  |
| `id` | string |  |

## Reading the answer

- GET /v1/sftp answers the host and port to connect to, your user name, the two folders, the kinds of file read, your SSH keys and how long files are kept (`retention`).

## Troubleshooting

- `sftp_not_open` (403): The file drop is not open on this deployment. The API routes are the same work, call by call.
- `private_key_sent` (400): That was a private key. Nothing was kept: send the .pub file, and keep the private key to yourself.

Every refusal these routes may answer, with its status. Any route may also answer the codes every route shares, on [Every error](https://payerworks.com/docs/errors).

| Code | Status | What it means |
| --- | --- | --- |
| `invalid_acknowledgment_999` | 400 | acknowledgment_999 is "always", "errors_only", "never": when a 999 is written for a file you drop. |
| `invalid_ssh_key_id` | 400 | An SSH key id is key- followed by 17 lowercase hexadecimal characters, as GET /v1/sftp lists it. |
| `invalid_ssh_public_key` | 400 | public_key is one OpenSSH public key line (ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521), as the .pub file holds it. |
| `private_key_sent` | 400 | That is a PRIVATE key. Nothing was kept: send the matching public key (the .pub file), and keep the private key on your own machine. |
| `unknown_sftp_field` | 400 | PUT /v1/sftp takes acknowledgment_999 and nothing else. An SFTP user signs in with an SSH key only: add one with POST /v1/sftp/ssh-keys. |
| `sftp_not_open` | 403 | The SFTP file drop is not open on this Payerworks deployment. Every API route still answers. |
| `sftp_user_not_found` | 404 | This key has no SFTP user. Make one with PUT /v1/sftp. |
| `ssh_key_not_found` | 404 | This SFTP user holds no SSH key with that id. |
| `ssh_key_exists` | 409 | This SFTP user already holds that SSH key. |
| `ssh_key_limit_reached` | 409 | An SFTP user holds at most 10 SSH keys. Remove one to add another. |
| `ssh_key_refused` | 422 | The SFTP endpoint refused that SSH key, so nothing was added. Send another public key; an ssh-ed25519 key is the simplest. |
| `sftp_unavailable` | 503 | The SFTP user could not be read or changed just now, so nothing changed. Send the request again. |

## Code lists

### How long files are kept

- A file you drop in inbound is read once and deleted as soon as its answers are written. A file the lane could not reach is deleted by the bucket 1 day after it arrived; S3 removes an expired file in the day or so after its expiry.
- Each answer (a TA1, a 999 and the report) waits in outbound for 7 days after it was written, then the bucket deletes it; you may delete it sooner. S3 removes an expired file in the day or so after its expiry.
- The bucket keeps no earlier version of a file and is in no backup. Nothing you drop is stored anywhere else, logged or sent on: the lane's own log line names the key, the kind of file and the outcome, never its contents or its name.
- The endpoint's transfer log in CloudWatch records the name of each file you move, so never put a patient's name or any identifier in a file name.
- Nothing is sent to a payer: Payerworks validates and acknowledges the file, and you send the claims on yourself.
- When your key is revoked, its SFTP user is removed at once; when it expires, or when the endpoint could not be reached at the revoke, by the next daily pass, within a day. A file dropped under a revoked or expired key before then is deleted unread, with no answer.

## Example

[sftp.mjs](https://payerworks.com/docs/examples/sftp.mjs) runs every step below against the API with a test key. It needs [payerworks.mjs](https://payerworks.com/sdk/payerworks.mjs) and [sample-data.mjs](https://payerworks.com/docs/examples/sample-data.mjs) beside it.

sftp.mjs

```js
// Payerworks example: the SFTP file drop. Docs: https://payerworks.com/docs/sftp
//
// Run it with Node 18 or later, with the JavaScript client (https://payerworks.com/sdk/payerworks.mjs)
// and sample-data.mjs beside this file:
//   PAYERWORKS_API_KEY=pw_test_... PAYERWORKS_BASE_URL=<the base URL shown with your key> node sftp.mjs
// A test key and invented data only. Payerworks sends nothing to a payer: every file it writes is yours.
import { pathToFileURL } from 'node:url';
import { Payerworks, PayerworksError } from './payerworks.mjs';
import { refusalOf } from './sample-data.mjs';

/** @param {Payerworks} pw */
export async function run(pw) {
  // The drop is open only where Payerworks has turned it on. Where it is not, every SFTP route
  // answers 403 sftp_not_open, and every other route is the same work, call by call.
  const closed = await refusalOf(() => pw.getSftp(), { PayerworksError });
  if (closed?.code === 'sftp_not_open') return { open: false, refused: closed };
  if (closed?.code === 'sftp_user_not_found') {
    // Open, and no user yet: make one. Add your SSH public key with POST /v1/sftp/ssh-keys (there are no passwords).
    const user = await pw.putSftp({ acknowledgment_999: 'errors_only' });
    return { open: true, host: user.host, inbound: user.folders.inbound, outbound: user.folders.outbound };
  }
  if (closed) return { open: null, refused: closed };
  const user = await pw.getSftp();
  return { open: true, host: user.host, inbound: user.folders.inbound, outbound: user.folders.outbound };
}

// Run as a script: node <this file>, with PAYERWORKS_API_KEY and PAYERWORKS_BASE_URL set.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  console.log(JSON.stringify(await run(new Payerworks()), null, 2));
}
```

What it answered, run against the API in Payerworks’ own tests (your request ids will differ)

```json
{
  "open": false,
  "refused": {
    "status": 403,
    "code": "sftp_not_open",
    "request_id": "req_1"
  }
}
```
