# Ravenkeep CSV import guide

How to export a product's past customers and revenue into the two CSV files the Ravenkeep console imports (**Settings > Projects > your project > Import**). It is written so that a developer or an AI agent can write the export script from this page alone.

## What gets imported

Two files. `contacts.csv` has one row per customer: signup date, email, name, account and first-touch attribution. `revenue.csv` has one row per payment or refund. Import the contacts first: every revenue row needs its customer.

- Imported data appears at its true dates: signups per day, customers, paying customers, LTV, revenue over time and the Attribution report.
- **Past behavioral events are not imported**, and an import records none: no `$signup`, `$revenue` or `$identify` events, no active-user days (DAU, WAU and MAU do not move), no campaign flow enrollment, no goal conversions and no operator notifications.
- Importing the same files again changes nothing. Customers are matched by `userId` and payments by `kind` plus `reference`, also against the revenue your backend already sends live, so nothing is counted twice.
- Only owners and admins of the organization can import. The importer lists every import with who ran it, when, the file and its counts.

> **Use the ids of your live integration**
>
> `userId` must be exactly the id your backend passes as `userId` to `@ravenkeep/node` (`signup`, `identify`, `revenue`), and `reference` exactly the payment id your backend passes to `revenue()`. Then imported and live data merge into one customer and one revenue ledger.

## File format

| Rule | Value |
| --- | --- |
| Encoding | UTF-8. A byte order mark (BOM) is accepted and ignored. A file that is not valid UTF-8 is refused. |
| Header | The first line names the columns. Names match ignoring case, spaces, `_`, `-` and `.`, so `signed_up_at`, `SignedUpAt` and `signedUpAt` are the same column. Order does not matter. Unknown columns are listed and ignored; a missing required column refuses the file. |
| Delimiter | Comma (recommended). A file delimited by semicolons or tabs is detected from its header line. |
| Quoting | RFC 4180: wrap a value in double quotes when it contains the delimiter, a double quote or a line break, and double every quote inside it (`"Studio ""Norte"""`). Use your language's CSV library instead of joining strings. |
| Line endings | LF or CRLF. Blank lines are skipped, and so are lines of only spaces and delimiters (a spreadsheet's empty row, `,,,`). |
| Empty cell | No value. Spaces around a value are trimmed. |
| Optional columns | Only the required columns must be in the header. Leave an optional column out, or leave its cells empty. |
| Lengths | Limits count characters as JavaScript does (`string.length`, UTF-16 code units), after trimming. A value over its limit rejects the row (`too_long`), except in the attribution columns, which are cut to the limit. |
| Size | At least one row; at most 250,000 rows and 64 MiB per file. Skip a file that would hold only its header. Split a bigger export into several files and import them one after the other: each file is independent. |
| Row numbers | Errors name the line where the row starts; the header is line 1. |

A row with fewer values than the header, or with extra values that are not empty, is rejected as `malformed_row` (usually an unquoted comma or line break). Extra empty values, such as a trailing comma, are ignored.

## contacts.csv

One row per customer. Required columns: `userId` and `signedUpAt`.

| Column | Required | Format | Notes |
| --- | --- | --- | --- |
| `userId` | yes | Text, 1 to 255 characters, no control characters | Your product's stable user id: exactly the `userId` your backend sends to `@ravenkeep/node`. Once per file. |
| `signedUpAt` | yes | Date-time with a zone, such as `2025-03-14T09:26:53Z` | When the customer created their account. Drives signups per day and the Attribution cohort. |
| `email` | no | An address with one `@` and no spaces, up to 320 characters | The customer's address. Campaign flows may email it. A row whose address does not have that shape is rejected: leave the cell empty when an address is unusable. |
| `name` | no | Text, up to 200 characters | Display name. |
| `accountId` | no | Text, up to 255 characters | The customer's company, workspace or organization id in your product (the SDK's `account.id`). |
| `accountName` | no | Text, up to 200 characters | The account's display name. Needs `accountId`. |
| `utmSource` | no | Text, up to 255 characters (longer values are cut) | `utm_source` of the first visit, such as `google`. |
| `utmMedium` | no | Text, up to 255 characters (longer values are cut) | `utm_medium`, such as `cpc`. |
| `utmCampaign` | no | Text, up to 255 characters (longer values are cut) | `utm_campaign`. |
| `utmTerm` | no | Text, up to 255 characters (longer values are cut) | `utm_term`. |
| `utmContent` | no | Text, up to 255 characters (longer values are cut) | `utm_content`. |
| `gclid` | no | Text, up to 512 characters (longer values are cut) | Google Ads click id. |
| `fbclid` | no | Text, up to 512 characters (longer values are cut) | Meta click id. |
| `msclkid` | no | Text, up to 512 characters (longer values are cut) | Microsoft Ads click id. |
| `referrer` | no | Text, up to 2048 characters (longer values are cut) | The referring page of the first visit: an absolute `http(s)` URL, else dropped. |
| `landingPage` | no | Text, up to 2048 characters (longer values are cut) | The first page opened: an absolute `http(s)` URL or a path starting with `/`, else dropped. |
| `attributionCapturedAt` | no | Date-time with a zone | When the first touch happened. Defaults to `signedUpAt`; ignored when every attribution column is empty. |

Fill the attribution columns only with what you stored when the customer signed up (the first touch: the UTM parameters, click ids, referrer and landing page of their first visit). Values longer than the limit are cut. Leave every attribution column empty when you never stored it; never guess.

## revenue.csv

One row per payment or refund that actually moved money. Required columns: `userId`, `amount`, `currency`, `reference` and `occurredAt`.

| Column | Required | Format | Notes |
| --- | --- | --- | --- |
| `userId` | yes | Text, 1 to 255 characters | The paying customer: a `userId` of `contacts.csv` or one Ravenkeep already knows from the SDK. |
| `amount` | yes | Integer in minor units (cents), 0 to 1000000000000 | `1999` is 19.99. Digits only: no decimal point, sign, symbol or separator. Always positive, refunds too. |
| `currency` | yes | ISO 4217 code, such as `USD` | Must be the project's currency. |
| `kind` | no | `payment` or `refund` | Defaults to `payment`. A refund subtracts its amount from the customer's LTV. |
| `reference` | yes | Text, 1 to 255 characters, no control characters | The stable id of the payment in your billing system (Stripe `in_...`, `ch_...` or `re_...`). With `kind`, the idempotency key. |
| `occurredAt` | yes | Date-time with a zone | When the money moved (the invoice's paid time, the refund's creation time). |

- Export only payments that succeeded: paid invoices, captured charges. Skip drafts, open, void and uncollectible invoices and failed charges.
- Invoices of zero can be left out: a customer becomes paying only with a payment above zero.
- One row per refund, with the refunded amount (positive) and `kind` `refund`. A partial refund is a refund of the partial amount.
- Revenue belongs to a person: map an organization's invoices to the user who pays for it (its owner or billing contact), and include that user in `contacts.csv`.
- For a payment your backend already sent live with `revenue()`, use the same `reference`, `amount` and customer it sent. A different amount or customer is reported as `reference_conflict`, the live record is kept and nothing is counted twice.

## Dates and times

`signedUpAt`, `occurredAt` and `attributionCapturedAt` are instants with a time zone. Write them in UTC with a `Z`.

| Value | Accepted |
| --- | --- |
| `2025-03-14T09:26:53Z` | Yes (recommended). |
| `2025-03-14T09:26:53.123Z` | Yes. Fractions are kept to the millisecond. |
| `2025-03-14T06:26:53-03:00` | Yes, stored as `2025-03-14T09:26:53Z`. |
| `2025-03-14 09:26:53.123456+00` | Yes (the PostgreSQL text form of `timestamptz`). |
| `2025-03-14` | No: no time and no zone. |
| `2025-03-14 09:26:53` | No: no zone. If the source column holds UTC (a `timestamp without time zone` or a MySQL `DATETIME` written in UTC), append `Z`. |
| `1741944413` | No: convert epoch seconds (Stripe timestamps) to ISO 8601 first. |

Dates must fall between the years 1900 and 9999 and be at most 5 minutes after the import's clock. Daily charts group by UTC day.

## Amounts and currency

`amount` is an integer in the currency's minor unit (cents), the unit Stripe and `revenue()` of `@ravenkeep/node` use. Digits only: no decimal point, sign, currency symbol or thousands separator. Nothing is rounded: a value with decimals is rejected.

| Price | `currency` | `amount` |
| --- | --- | --- |
| 19.99 US dollars | `USD` | `1999` |
| R$ 49,90 | `BRL` | `4990` |
| 500 yen (no minor unit) | `JPY` | `500` |
| 1.250 Kuwaiti dinars (three decimals) | `KWD` | `1250` |

`currency` must be the project's currency (shown in the importer). A row in another currency is rejected with `currency_mismatch`: Ravenkeep does not convert. Set the project currency before the first revenue import, because it cannot change once the project has revenue.

A payment adds its amount to the customer's lifetime value (LTV) and a refund subtracts it. A customer becomes paying at their earliest payment above zero.

## Idempotency and merge rules

Import as often as you like: an import never creates a second customer and never counts a payment twice. Run the same files again after fixing rejected rows; finished rows come back as unchanged.

| Record | Key | Rule |
| --- | --- | --- |
| Customer | `userId` | One customer per project and `userId`. A `userId` may appear once per `contacts.csv`; later rows with it are rejected as `duplicate_user_id`. |
| Payment or refund | `kind` + `reference` | Already recorded with the same customer, amount and currency (by an earlier import, or live by `revenue()`): **unchanged**. With another customer, amount or currency: rejected as `reference_conflict`, and the stored record is kept. `occurredAt` is not compared. |

### When the customer already exists

A customer your live integration already sent, or an earlier import created, is completed, never overwritten:

| Field | Rule |
| --- | --- |
| `signedUpAt` | The earliest of the stored and the imported date wins. |
| `email` | Written only when the customer has no email. Imported addresses are marked as imported; campaign flows may email them, and a later call from your backend replaces them. An address that unsubscribed in the project (in any letter case, on any customer) stays unsubscribed: campaign flows skip it. |
| `name` | Written only when empty. |
| `accountId`, `accountName` | Written only when the customer has no account. When the stored account has the same id and no name, the name is filled. |
| Attribution columns | Written only when the customer has no attribution yet. The first touch is never replaced. |
| First and last seen | A new customer is first and last seen at its signup date. For an existing one, first seen moves back to the signup date when it was later and last seen never moves: an import is not activity. |
| Lifetime value | Changed only by new rows of `revenue.csv`. |

An imported customer counts as signed up: a later `signup()` from your backend for the same `userId` does not enroll them in signup flows again. Live events after the import trigger flows as usual.

> **Refund references**
>
> A refund may reuse its payment's reference (the `kind` differs), but then that payment can have only one refund row. Prefer the refund's own id (Stripe `re_...`) as `reference`: several partial refunds of one invoice stay distinct, and they match what your backend sends with `revenue({ kind: 'refund' })`.

## Mapping a typical SaaS database

Read from a replica or with a read-only user: the export never writes to the source. The usual sources:

| Column | Typical source |
| --- | --- |
| `userId` | `users.id`, the same id your backend sends to the SDK. |
| `email`, `name` | `users.email`, `users.name` (or first and last name joined with a space). |
| `signedUpAt` | `users.created_at`. |
| `accountId`, `accountName` | The user's organization, workspace or team (`organizations.id`, `organizations.name`). When a user belongs to several, pick one deterministically: the one the user last used, else the earliest membership, ties broken by id. |
| Attribution columns | The UTM columns stored at signup, if any. Otherwise leave them empty. |
| revenue `userId` | The customer your live integration credits for that payment: usually the organization's owner (earliest owner membership) or billing contact. If the payer changed after a payment was sent live, the import reports it as `reference_conflict` and keeps the live record. |
| `amount` | The amount your backend sends to `revenue()` for that payment (Stripe invoice `total` or `amount_paid`, whichever it uses); charge `amount_captured`; refund `amount`. |
| `currency` | The invoice, charge or refund currency, uppercased. |
| `reference` | Stripe invoice id `in_...` (charge id `ch_...` without invoices); refund id `re_...`. |
| `occurredAt` | Invoice `status_transitions.paid_at`, charge `created`, refund `created` (epoch seconds: convert to ISO 8601 UTC). |

- Leave out internal, staff and test accounts, and payments of other products that share the billing account (filter them by their metadata).
- Keep inactive users that were real customers: their history is part of your signups and revenue. If your product deletes users for good, their history is gone: export what remains (their organization's payments go to its current payer).
- Leaving someone out of the export does not remove them from Ravenkeep: customers your live integration already sent stay.
- Sort the output (contacts by `signedUpAt` then `userId`, revenue by `occurredAt` then `reference`) so two runs write the same files.

**contacts.sql**

```sql
-- psql: \copy (<this query on one line>) TO 'contacts.csv' WITH (FORMAT csv, HEADER)
SELECT u.id AS "userId",
       u.email AS "email",
       u.name AS "name",
       to_char(u.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "signedUpAt",
       o.id AS "accountId",
       o.name AS "accountName"
FROM users u
LEFT JOIN LATERAL (
  SELECT org.id, org.name
  FROM memberships m JOIN organizations org ON org.id = m.organization_id
  WHERE m.user_id = u.id
  -- the organization the user last used, else the earliest membership
  ORDER BY (m.organization_id = u.last_organization_id) DESC, m.created_at, org.id
  LIMIT 1
) o ON true
ORDER BY u.created_at, u.id;
```

**revenue.sql**

```sql
SELECT payer.user_id AS "userId",
       i.total AS "amount", -- the amount your live revenue() call sends
       upper(i.currency) AS "currency",
       'payment' AS "kind",
       i.stripe_invoice_id AS "reference",
       to_char(i.paid_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "occurredAt"
FROM invoices i
JOIN LATERAL (
  -- the organization's earliest owner pays
  SELECT m.user_id FROM memberships m
  WHERE m.organization_id = i.organization_id AND m.role = 'owner'
  ORDER BY m.created_at, m.user_id
  LIMIT 1
) payer ON true
WHERE i.status = 'paid' AND i.total > 0
ORDER BY i.paid_at, i.stripe_invoice_id;
```

## Example files

**contacts.csv**

```csv
userId,email,name,signedUpAt,accountId,accountName,utmSource,utmMedium,utmCampaign
u_1001,ana@example.com,Ana Souza,2024-11-02T14:05:31Z,org_77,Acme Ltda,google,cpc,brand
u_1002,bruno@example.com,"Bruno, Jr.",2025-01-15T09:00:00Z,org_77,Acme Ltda,,,
u_1003,,Carla Dias,2025-02-20T22:47:10.250Z,org_81,"Studio ""Norte""",newsletter,email,feb-launch
```

**revenue.csv**

```csv
userId,amount,currency,kind,reference,occurredAt
u_1001,1999,USD,payment,in_1QaB2cD3eF4gH5iJ,2024-11-02T14:07:02Z
u_1001,1999,USD,payment,in_1QkL6mN7oP8qR9sT,2024-12-02T14:07:11Z
u_1003,4990,USD,payment,in_1QuV0wX1yZ2aB3cD,2025-02-20T22:50:00Z
u_1003,4990,USD,refund,re_3QeF4gH5iJ6kL7mN,2025-02-27T10:12:45Z
```

## Row errors

The dry run and the import report every rejected row with its line, code, column and message, and the console downloads them as a CSV to fix. The other rows of the file are imported.

| Code | Meaning | Fix |
| --- | --- | --- |
| `required` | A required value is empty. | Fill the column for that row, or leave the row out. |
| `invalid_format` | A value has the wrong shape: a date without a zone, an amount with decimals, an invalid email. | Write the value in the format of the column table. |
| `too_long` | A value is longer than the column allows. | Shorten it. |
| `out_of_range` | A number or date is outside the accepted range (a future date, an amount above the cap, a negative amount). | Check the source value; refunds are positive amounts with `kind` `refund`. |
| `invalid_value` | `kind` is not `payment` or `refund`. | Use one of the two values. |
| `malformed_row` | The row has fewer values than the header, or extra non-empty values. | Quote values that contain the delimiter or a line break. |
| `duplicate_user_id` | The `userId` already appeared earlier in `contacts.csv`; the first valid row wins. | Export one row per customer. |
| `currency_mismatch` | The currency is not the project's currency. | Import only revenue in the project currency; Ravenkeep does not convert. |
| `unknown_contact` | No customer with this `userId` exists in the project. | Add the customer to `contacts.csv` and import it first, or fix the payer id. |
| `reference_conflict` | This `kind` and `reference` is already recorded with another customer, amount or currency. | Check which record is right; references must identify one payment. |

## Running the import

- **Open the importer**: Settings > Projects > your project > Import (owners and admins).
- **Add the files**: `contacts.csv` and, optionally, `revenue.csv`. The console checks the columns and every row in the browser.
- **Dry run**: counts the rows that would be created, updated, unchanged or rejected. Nothing is written.
- **Import**: rows go in batches of up to 1,000 rows (and about 1 MiB), contacts before revenue. Keep the tab open. If it stops (a closed tab, a network failure), import the same file again: finished rows come back unchanged.
- **Fix rejected rows**: download them, correct them and import that file.

## Checklist for an export script

- Read only: never write to the source database or billing account.
- Write two UTF-8 files with a header row and the column names of this page, comma delimited, quoted by a CSV library.
- `userId` and `reference` are the values your live integration sends.
- Date-times in UTC with `Z`; amounts as integers in minor units; `currency` uppercase and equal to the project currency.
- One row per `userId` in `contacts.csv`; one row per payment or refund in `revenue.csv`; every revenue `userId` present in `contacts.csv` (or already known to Ravenkeep).
- Leave out internal and test accounts; leave attribution columns empty unless you stored them.
- Sort the rows so the output is deterministic.
- The files hold personal data: write them outside version control and delete them after the import.
- Print what was written and what was skipped (and why), so a person can compare it with the dry run.

---

Published at https://ravenkeep.io/docs/import (Markdown: https://ravenkeep.io/docs/import.md).
