> ## Documentation Index
> Fetch the complete documentation index at: https://docs.revyops.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Activities

> Log calls, emails, LinkedIn messages, SMS, WhatsApp messages, and your own custom activity types onto a contact or company timeline.

## Overview

An activity is a timestamped event on a contact's or company's timeline — a call, an email, a LinkedIn message, or something specific to your business like "Demo Booked" or "Contract Signed." The Activities API supports two different kinds of activity, and picking the right one matters:

<CardGroup cols={2}>
  <Card title="Standard activities" icon="phone">
    Five fixed, built-in channels: `call`, `email`, `linkedin_message`, `sms`, `whatsapp`. Logging one writes directly into the same core tables (`Calls`, `Emails`, `LinkedInMessages`, `SMSMessages`, `WhatsAppMessages`) that RevyOps's own outreach integrations populate. Use these for real communication events you're capturing outside of a connected integration.
  </Card>

  <Card title="Custom activities" icon="list-check">
    Activity types you define yourself — a name, which object they apply to (contact or company), and an optional field schema. This is the same generic mechanism RevyOps uses internally to mirror HubSpot and Close CRM activity types. Use these for milestones and events that don't fit one of the five standard channels.
  </Card>
</CardGroup>

Activity ingestion feeds directly into lead scoring: a standard activity always enqueues its matched contact for rescoring, and a contact-scoped custom activity enqueues every contact it linked. Company-scoped custom activities don't feed scoring at all. In every case, this is a no-op if scoring isn't enabled for your workspace, or if the activity didn't end up linked to any contact.

### The `source` field

Every logging endpoint accepts an optional `source` field. If you omit it (or send `"revyops"`), the activity is attributed to the built-in `revyops` origin and no extra setup is needed.

If you pass anything else, that value must already be registered as a source under **Settings → Activities & Scoring** in the RevyOps dashboard, and it must be explicitly enabled for the specific activity type you're logging — standard activities are bound per channel (e.g. `call`), custom activities are bound per activity type. An unregistered or unbound source returns a `422`:

```json theme={null}
{
  "error": "Unprocessable Entity",
  "message": "Unknown source 'zapier'. Add it under Activities & Scoring first."
}
```

or, if the source exists but isn't enabled for that activity type:

```json theme={null}
{
  "error": "Unprocessable Entity",
  "message": "Source 'zapier' is not enabled for call activities. Enable it under Activities & Scoring first."
}
```

<Tip>
  The Activities & Scoring page will hand you a ready-to-use webhook URL and a sample payload for whichever activity type you're binding a source to — useful for confirming the exact endpoint and field names before you write code against them.
</Tip>

All endpoints below sit under the same base path and authentication as the rest of the public API — see [API Introduction](/api-reference/introduction). Activities endpoints only exist at `/api/public/activities/...` — there is no `v2` equivalent.

## List custom activity types

```http theme={null}
GET /api/public/activities/types/
X-API-KEY: YOUR_API_KEY
```

Returns every custom activity type defined for your workspace. This only returns activity types created through this API or the Activities & Scoring page (`origin: "revyops"`) — it does not include the activity types RevyOps auto-creates to mirror synced HubSpot or Close CRM activities.

```json theme={null}
[
  {
    "id": "3f1b2c4a-9e3d-4a7b-8e2f-1a2b3c4d5e6f",
    "name": "Demo Booked",
    "schema": [
      { "field_name": "deal_size", "type": "number", "available_options": [] },
      { "field_name": "stage", "type": "dropdown_singleselect", "available_options": ["SQL", "Demo", "Closed Won"] }
    ],
    "applies_to": "contact",
    "origin": "revyops"
  }
]
```

<Note>
  The `id` field here is the value you pass as `activity_key` in every other endpoint on this page. It's a server-generated UUID, not a sequential database ID — there's no way to choose your own key.
</Note>

## Create a custom activity type

```http theme={null}
POST /api/public/activities/types/
X-API-KEY: YOUR_API_KEY
```

| Field        | Type   | Required | Description                                           |
| ------------ | ------ | -------- | ----------------------------------------------------- |
| `name`       | string | Yes      | Display name for the activity type.                   |
| `applies_to` | string | No       | `contact` (default) or `company`.                     |
| `schema`     | array  | No       | Field definitions for this activity type — see below. |

Each entry in `schema` describes one property you'll be able to attach when logging an instance of this activity:

| Field               | Type             | Required | Description                                                                                      |
| ------------------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------ |
| `field_name`        | string           | Yes      | The key you'll use in `properties` when logging an activity.                                     |
| `type`              | string           | Yes      | One of `text`, `number`, `date`, `dropdown_singleselect`, `dropdown_multiselect`.                |
| `available_options` | array of strings | No       | Allowed values for a dropdown field. If omitted or empty, any string is accepted for that field. |

```json theme={null}
{
  "name": "Demo Booked",
  "applies_to": "contact",
  "schema": [
    { "field_name": "deal_size", "type": "number" },
    { "field_name": "stage", "type": "dropdown_singleselect", "available_options": ["SQL", "Demo", "Closed Won"] }
  ]
}
```

Returns `201` with the created type in the same shape as the list endpoint, including the generated `id` (your `activity_key` going forward). A malformed payload — missing `name`, or a `type` outside the allowed set — returns `400` with a field-keyed validation error.

## Log a custom activity

```http theme={null}
POST /api/public/activities/custom/
X-API-KEY: YOUR_API_KEY
```

| Field          | Type              | Required | Description                                                                                                                                             |
| -------------- | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `activity_key` | string            | Yes      | The `id` of the custom activity type, from the list/create endpoints above.                                                                             |
| `occured_at`   | ISO 8601 datetime | Yes      | When the activity happened.                                                                                                                             |
| `email`        | string            | No       | Used to find and link a matching contact.                                                                                                               |
| `linkedin_url` | string            | No       | Used to find and link a matching contact.                                                                                                               |
| `phone`        | string            | No       | Used to find and link a matching contact.                                                                                                               |
| `domain`       | string            | No       | Used to find and link a matching company. Only takes effect when the activity type's `applies_to` is `company` — it's ignored for contact-scoped types. |
| `source`       | string            | No       | See [The `source` field](#the-source-field) above.                                                                                                      |
| `properties`   | object            | No       | Values for the fields defined on the activity type's schema.                                                                                            |

<Warning>
  `occured_at` is spelled exactly like that in the real API — it's missing the second "r." This isn't a typo in this doc; sending the correctly-spelled `occurred_at` will fail validation because the field the API actually reads is `occured_at`.
</Warning>

```json theme={null}
{
  "activity_key": "3f1b2c4a-9e3d-4a7b-8e2f-1a2b3c4d5e6f",
  "occured_at": "2026-07-27T14:30:00Z",
  "email": "lead@example.com",
  "source": "zapier",
  "properties": {
    "deal_size": 4200,
    "stage": "Demo"
  }
}
```

Returns `201` with `{"id": <activity id>}` on success.

**Property validation.** Any key in `properties` that isn't in the activity type's schema returns `422` with an `invalid_fields` list. Keys that are in the schema are validated by their declared `type`: `number` values must parse as numeric, `date` values must be `YYYY-MM-DD`, `dropdown_singleselect` values must be one of `available_options` (if any are defined), and `dropdown_multiselect` values must be a list of strings, each one of `available_options` (if any are defined). A blank or `null` value skips type validation entirely. Type mismatches return `400`, not `422`.

**Linking behavior.** For a `contact`-scoped type, RevyOps checks `email`, `linkedin_url`, and `phone` independently and links every one that matches an existing contact — so if `email` matches one contact and `phone` matches a different one, the activity can end up linked to more than one contact. (This is different from standard activities below, where only a single contact is ever matched.) For a `company`-scoped type, only `domain` is checked, against `Companies.domain` (also lowercased and trimmed before matching).

If none of the identifiers you send match an existing contact or company — or you don't send any — the activity is still created and returns `201`. It's just left unlinked: it exists in your workspace but won't appear on any specific contact's or company's timeline, and no error is raised.

Email is lowercased and trimmed before matching. `linkedin_url` is normalized to its canonical `linkedin.com/in/<handle>` form. `phone` is parsed to E.164; if it can't be parsed, it's silently dropped from the match attempt rather than causing an error.

## Update a custom activity type

```http theme={null}
PATCH /api/public/activities/custom/
X-API-KEY: YOUR_API_KEY
```

<Warning>
  This is the same path as [Log a custom activity](#log-a-custom-activity) above, but `PATCH` on it edits the activity **type** definition, not an individual logged activity instance. There is no endpoint to edit an already-logged activity instance.
</Warning>

| Field          | Type   | Required | Description                                             |
| -------------- | ------ | -------- | ------------------------------------------------------- |
| `activity_key` | string | Yes      | The `id` of the activity type to update.                |
| `name`         | string | No       | New display name.                                       |
| `schema`       | array  | No       | Replaces the entire field schema — this is not a merge. |
| `applies_to`   | string | No       | `contact` or `company`.                                 |

Only the fields you include are changed. Returns `200` with the updated type object, or `404` if `activity_key` doesn't match an existing type.

Renaming a type also renames any scoring rule in Activities & Scoring that's attached to it.

## Delete a custom activity type

```http theme={null}
DELETE /api/public/activities/custom/
X-API-KEY: YOUR_API_KEY
```

```json theme={null}
{ "activity_key": "3f1b2c4a-9e3d-4a7b-8e2f-1a2b3c4d5e6f" }
```

Returns `204` on success, or `404` if `activity_key` doesn't match an existing type. Returns `409` if any activities have already been logged against this type:

```json theme={null}
{
  "error": "Conflict",
  "message": "Cannot delete custom activity type because existing custom activities use it."
}
```

This 409 fires as soon as a single activity has ever been logged against the type — including one that ended up unlinked (see [Log a custom activity](#log-a-custom-activity) above), since it's still a row referencing this type. The public API has no endpoint to delete a logged activity instance, so once a type has been used even once, it can no longer be deleted through this API — deletion only works for a type nothing has ever been logged against. On a successful delete, RevyOps also strips this type's binding from any source configured under Activities & Scoring, so a source that used to feed this type won't silently reference a dangling ID.

## Standard activities: shared request shape

The five endpoints below (`call`, `email`, `linkedin_message`, `sms`, `whatsapp`) all share the same top-level fields — only `properties` differs per channel, documented in each section:

| Field          | Type   | Required | Description                                                    |
| -------------- | ------ | -------- | -------------------------------------------------------------- |
| `email`        | string | No\*     | Used to find the contact this activity belongs to.             |
| `linkedin_url` | string | No\*     | Used to find the contact this activity belongs to.             |
| `phone`        | string | No\*     | Used to find the contact this activity belongs to.             |
| `source`       | string | No       | See [The `source` field](#the-source-field) above.             |
| `properties`   | object | No\*     | Channel-specific fields — see the table in each section below. |

\* `properties` isn't required by name, but every channel has at least one required field inside it (e.g. `direction` for calls), so omitting `properties` entirely still fails validation on that field. At least one of `email`/`linkedin_url`/`phone` that resolves to an existing contact is also effectively required — see below.

**Standard activities never create a contact.** RevyOps checks `email` first, then `linkedin_url`, then `phone`, and links to the *first* one that matches an existing contact — unlike custom activities, only ever one contact is linked. If none of the identifiers you send match an existing contact, the request fails with `422`:

```json theme={null}
{
  "error": "Unprocessable Entity",
  "message": "No contact matched the given email/linkedin_url/phone. A matching contact is required for standard activities."
}
```

There's no `domain` field on these endpoints — the activity's company is set automatically from the matched contact's company.

Any key inside `properties` that isn't in that channel's table below returns `422`:

```json theme={null}
{
  "error": "Unprocessable Entity",
  "message": "The following activity field(s) are not supported for this activity type.",
  "invalid_fields": ["custom_notes"]
}
```

A recognized field with an invalid value (e.g. `direction: "sideways"`) returns `400` with a standard field-keyed validation error. On success, every endpoint below returns `201` with `{"id": <activity id>}`.

## Log a call

```http theme={null}
POST /api/public/activities/standard/call/
X-API-KEY: YOUR_API_KEY
```

| `properties` field | Type              | Required | Description                                                                |
| ------------------ | ----------------- | -------- | -------------------------------------------------------------------------- |
| `direction`        | string            | Yes      | `inbound` or `outbound`.                                                   |
| `timestamp`        | ISO 8601 datetime | No       | When the call happened. Defaults to the time the API received the request. |
| `duration`         | integer           | No       | Call length in seconds. Must be `0` or greater.                            |
| `body_text`        | string            | No       | Free text — transcript, summary, or notes.                                 |

```json theme={null}
{
  "phone": "+14155552671",
  "properties": {
    "direction": "inbound",
    "duration": 184,
    "body_text": "Discussed pricing, follow-up scheduled for Friday."
  }
}
```

## Log an email

```http theme={null}
POST /api/public/activities/standard/email/
X-API-KEY: YOUR_API_KEY
```

| `properties` field | Type              | Required | Description                                        |
| ------------------ | ----------------- | -------- | -------------------------------------------------- |
| `email_type`       | string            | Yes      | `inbound`, `outbound`, or `bounced`.               |
| `timestamp`        | ISO 8601 datetime | No       | Defaults to the time the API received the request. |
| `opened_at`        | ISO 8601 datetime | No       | When the email was opened.                         |
| `interested`       | boolean           | No       | Marks the email as an interested reply.            |
| `subject`          | string            | No       | Email subject line.                                |
| `body_text`        | string            | No       | Email body.                                        |

```json theme={null}
{
  "email": "lead@example.com",
  "properties": {
    "email_type": "inbound",
    "subject": "Re: Following up",
    "interested": true
  }
}
```

## Log a LinkedIn message

```http theme={null}
POST /api/public/activities/standard/linkedin_message/
X-API-KEY: YOUR_API_KEY
```

| `properties` field | Type              | Required | Description                                                                                                                                        |
| ------------------ | ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_type`       | string            | Yes      | One of `message_sent`, `message_reply_received`, `inmail_sent`, `inmail_reply_received`, `connection_request_sent`, `connection_request_accepted`. |
| `timestamp`        | ISO 8601 datetime | No       | Defaults to the time the API received the request.                                                                                                 |
| `message_text`     | string            | No       | Message content.                                                                                                                                   |

```json theme={null}
{
  "linkedin_url": "linkedin.com/in/lead",
  "properties": {
    "event_type": "message_reply_received",
    "message_text": "Sounds good, let's talk next week."
  }
}
```

## Log an SMS message

```http theme={null}
POST /api/public/activities/standard/sms/
X-API-KEY: YOUR_API_KEY
```

| `properties` field | Type              | Required | Description                                        |
| ------------------ | ----------------- | -------- | -------------------------------------------------- |
| `direction`        | string            | Yes      | `inbound` or `outbound`.                           |
| `timestamp`        | ISO 8601 datetime | No       | Defaults to the time the API received the request. |
| `message_text`     | string            | No       | Message content.                                   |

```json theme={null}
{
  "phone": "+14155552671",
  "properties": {
    "direction": "inbound",
    "message_text": "Yes, that time works."
  }
}
```

## Log a WhatsApp message

```http theme={null}
POST /api/public/activities/standard/whatsapp/
X-API-KEY: YOUR_API_KEY
```

Same shape as SMS above — WhatsApp and SMS share an identical properties schema.

| `properties` field | Type              | Required | Description                                        |
| ------------------ | ----------------- | -------- | -------------------------------------------------- |
| `direction`        | string            | Yes      | `inbound` or `outbound`.                           |
| `timestamp`        | ISO 8601 datetime | No       | Defaults to the time the API received the request. |
| `message_text`     | string            | No       | Message content.                                   |

```json theme={null}
{
  "phone": "+14155552671",
  "properties": {
    "direction": "outbound",
    "message_text": "Hey, following up on our call."
  }
}
```
