Report Cost Basis On Deposits
Attach cost basis to a customer's crypto deposit so their gains are reported correctly on Form 1099-DA
Why cost basis matters on a deposit
When a customer deposits crypto onto your platform from an external wallet, zerohash reports the deposit for tax purposes but has no visibility into what the customer paid for the asset or when they acquired it. The deposit is therefore treated as having unknown, effectively zero, cost basis. When the customer later sells, the entire proceeds look like gain, and the 1099-DA overstates what they owe.
If you already hold that acquisition history, these endpoints let you hand it over so the sale is reported against the real basis.
When to use this
- Migrating an existing book of business to zerohash. You hold the full acquisition history for balances you are transferring in.
- Supporting external-wallet deposits. Your customer acquired the asset elsewhere and you know the acquisition price and date.
This is opt-in. If you do not submit basis, deposits continue to be reported as they are today.
Endpoints
| Verb | Path | Purpose |
|---|---|---|
POST | /deposits/{deposit_id}/lots | Submit one or more cost-basis lots for a deposit |
GET | /deposits/{deposit_id}/lots | Read the lots currently on file, and their status |
DELETE | /deposits/{deposit_id}/lots | Remove the lots so basis can be corrected |
Finding the deposit_id
deposit_id is an identifier you already have. It is the deposit_id returned by the GET crypto deposits endpoints, and it is the same value as the movement_id returned by the GET deposits endpoint. There is no new identifier to learn.
Submit cost basis
Send one lot per acquisition. A deposit that was accumulated over several purchases takes several lots in a single request.
| Field | Required | Description |
|---|---|---|
lots | Yes | Array of one or more cost-basis lots. |
request_id | No | Your idempotency key for the create. zerohash generates a UUIDv4 when you omit it and echoes it in the response. Replaying the same request_id returns the existing record instead of creating a second one. |
effective_datetime | No | RFC3339 timestamp marking when the lots take effect for tax reporting. When omitted, no value is sent and the tax subsystem applies its own default. |
Each entry in lots:
| Field | Required | Description |
|---|---|---|
quantity | Yes | Quantity acquired in this lot, in units of the deposited asset. |
cost_basis | Yes | Total cost basis of the lot in USD. |
acquisition_transaction_datetime | No | RFC3339 timestamp of the original acquisition. When omitted, no value is sent and the tax subsystem applies its own default. |
Example POST /deposits/{deposit_id}/lots request:
{
"request_id": "21bdbb11-712f-4cb7-a178-4f277c80cde0",
"lots": [
{
"quantity": "1.5",
"cost_basis": "45000.00",
"acquisition_transaction_datetime": "2024-01-01T00:00:00Z"
},
{
"quantity": "0.5",
"cost_basis": "21000.00",
"acquisition_transaction_datetime": "2024-06-14T09:30:00Z"
}
]
}Example 201 response:
{
"message": {
"request_id": "21bdbb11-712f-4cb7-a178-4f277c80cde0",
"deposit_id": "21bdbb11-712f-4cb7-a178-4f277c80cde0",
"status": "reported",
"participant_code": "ABCDEF",
"account_label": "general",
"effective_datetime": "2024-01-01T00:00:00Z",
"lots": [
{
"quantity": "1.5",
"cost_basis": "45000.00",
"acquisition_transaction_datetime": "2024-01-01T00:00:00Z"
}
],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
}A
201means the submission was accepted, not that the basis was applied. The most common failure, lot quantities that do not sum to the deposit amount, still returns201and then lands the record in a failed state. Always confirm with aGETbefore treating basis as recorded.
You do not need to wait for the deposit to report
Submit basis whenever you have it. You do not have to time the request against zerohash's own reporting cycle.
- If the deposit has already been reported, the usual case, the lot is forwarded in the same call. The response comes back
reportedwhen the tax subsystem accepts it, orfailedwhen it rejects it. - If the deposit has not been reported yet, zerohash holds the submission and forwards it automatically once the deposit reports.
Submitting early is not an error you need to handle or retry. A held submission is retried on the zerohash side on a backoff until the deposit reports, for up to 24 hours from creation. Deletes are not queued: they are processed within the request.
Check what is on file
GET /deposits/{deposit_id}/lots returns the current record for the deposit, including status and, when something went wrong, error_message.
| Status | Meaning |
|---|---|
pending_transaction | Held. The deposit has not been reported yet, so the lots are waiting on it. |
ready | The deposit is resolved and the lots are waiting to be submitted. |
pending | Submission is in flight. |
reported | Accepted by the tax subsystem and used for gain/loss calculation. Terminal. |
failed | The last attempt failed. See below: this is not always final. |
deleted | The lots were removed. Terminal, and the request_id is spent. |
A failed record is normally retried automatically, on a backoff, for up to 10 attempts or 24 hours from creation. A failure the tax subsystem will never accept, a quantity mismatch for example, stops immediately instead. error_message tells the two apart: it is prefixed NON_RETRYABLE: when the submission was rejected outright, and MAX_RETRIES_EXCEEDED: when the retry budget ran out. In both of those cases the record is final, and correcting it means submitting again under a new request_id.
Correct or replace basis
A deposit carries one active lot record at a time. To change basis, delete the existing record, then submit the corrected lots under a new request_id. A deleted request_id is spent: reusing it returns 409.
DELETE /deposits/{deposit_id}/lots removes the lots recorded for the deposit. A delete is refused while a submission is in flight, so retry shortly if that happens.
A second
POSTunder a differentrequest_id, while an active record is on file, returns409. Delete the existing record first, then resubmit under a newrequest_id.
Rules and error responses
- Quantities must sum to the deposit amount. A mismatch does not return an HTTP error. The record lands
failedwith aNON_RETRYABLE:prefix onerror_message, and is not retried. - Digital assets only. A deposit of a fiat asset is rejected with
422. - One active record per deposit. A create under a different
request_idreturns409while an active record exists. Deleted and permanently failed records do not count as active. request_idis optional. zerohash generates a UUIDv4 when you omit it and returns it in the response. Replaying it returns the existing record; once that record is deleted or has permanently failed, the key is spent and returns409.
| Code | Meaning |
|---|---|
201 | Submission accepted, or an idempotent replay of an existing record. Confirm the outcome with a GET. |
400 | Validation failure. errors lists each problem. |
403 | Authentication or permission failure, including a key without write access. |
404 | The deposit does not exist, is not visible to your platform, or has no lots. |
409 | This deposit already has an active lot record, or the request_id belongs to a record that has been deleted or has permanently failed. |
422 | Transfer lots are not supported for this deposit's asset, for example fiat. POST only. |
500 | Unexpected server error. |
502 | Upstream error, including a delete attempted while a submission is in flight. Retry shortly. |
503 | A dependency was unavailable. Safe to retry; the response carries zh-allow-retry: true. |
Updated about 2 hours ago