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

# Publish a private module

> Issue credentials, publish an organization-owned module, install it, and send an event

This walkthrough uses the REST API to create a private module, publish its first version, install it, issue an ingest credential, and send a signed event.

You need an authenticated partner admin session. See [Admin session tokens](/authentication#admin-session-tokens) for how to obtain one and the [Quickstart](/quickstart) for the credential roles. Keep the session token and all raw credentials on your server. An `org_` key is a service identity for partner reads; it cannot publish a module or issue credentials. Keep the admin session for module authoring, then mint the `org_` key after the module is published and installed.

```bash theme={null}
export API_BASE_URL="https://api.develop.fourdos.dev"
export ADMIN_TOKEN="<partner admin session token>"
```

## 1. Create a private module draft

Create the module with the admin session. The server assigns the authenticated organization as `owner_organization_id`; the request does not accept an owner field. Partner admins create `third_party` modules. A request for `in_house` or `proprietary` returns `403`.

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/modules" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "club-ticketing",
    "name": "Club ticketing",
    "kind": "third_party",
    "vendor": "Your club"
  }'
```

```bash theme={null}
export MODULE_ID="<id from the response>"
```

Private modules are visible and installable only to their owning organization. Modules without an owner are platform or vendor modules visible to every organization.

A slug only has to be unique inside your organization, so pick the one that reads best to your team without checking what anyone else uses. The one case that answers `409` is a slug a platform or vendor module already holds, or one you already used yourself.

## 2. Create a version with its manifest

The manifest declares the module's native event type, the platform canonical event type it maps to, the native payload schema, and the projection.

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/modules/$MODULE_ID/versions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "version": "1.0.0",
    "manifest": {
      "module": {
        "slug": "club-ticketing",
        "kind": "third_party",
        "vendor": "Your club",
        "version": "1.0.0"
      },
      "emits": [
        {
          "native_type": "ticketing.ticket.purchased",
          "native_version": 1,
          "canonical_type": "commerce.purchase.completed",
          "canonical_version": 1,
          "schema": {
            "type": "object",
            "required": ["ticket_id", "amount_minor", "currency"],
            "properties": {
              "ticket_id": {"type": "string"},
              "amount_minor": {"type": "integer"},
              "currency": {"type": "string"}
            }
          },
          "projection": {
            "purchase_id": "$.ticket_id",
            "amount_minor": "$.amount_minor",
            "currency": "$.currency"
          }
        }
      ],
      "auth": {"mode": "hmac_api_key", "rotation_days": 90}
    }
  }'
```

```bash theme={null}
export VERSION_ID="<id from the response>"
```

## 3. Publish the version

Publish with the admin session, not the `org_` key:

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/modules/$MODULE_ID/versions/$VERSION_ID/publish" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
```

The manifest is checked against the platform module contract at publish time. Native event types are the module's to declare, while canonical event types are owned by the platform. If the manifest does not fit the contract, the request returns `422` and the version stays in draft.

## 4. Install the published version

Install the version for the owning organization with the admin session:

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/modules/$MODULE_ID/install" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"version_id":"'$VERSION_ID'"}'
```

```bash theme={null}
export INSTALLATION_ID="<id from the response>"
```

The installation pins the version whose manifest the ingest receptor uses.

## 5. Issue an organization API key

Now issue an `org_` key from the partner admin session for a service that needs partner reads. The `raw_key` is returned only in this response, so save it before continuing.

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/api-keys" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Module service"}'
```

```json theme={null}
{
  "id": "...",
  "organization_id": "...",
  "name": "Module service",
  "start": "org_...",
  "enabled": true,
  "created_at": "2026-09-04T00:00:00.000Z",
  "last_used_at": null,
  "raw_key": "org_..."
}
```

```bash theme={null}
export API_KEY="org_..."
```

`GET /v1/api-keys` returns masked metadata only. If the raw key is lost, revoke it and issue a replacement. An organization can have at most 10 enabled keys.

## 6. Issue an ingest credential

Issue a module credential for the installation. This credential is separate from the organization API key and is accepted only by `POST /v1/ingest`.

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/installations/$INSTALLATION_ID/credentials" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scopes":["ingest"],"ip_allowlist":[]}'
```

```json theme={null}
{
  "id": "...",
  "module_installation_id": "...",
  "organization_id": "...",
  "scopes": ["ingest"],
  "ip_allowlist": [],
  "last_used_at": null,
  "rotated_at": null,
  "revoked_at": null,
  "created_at": "2026-09-04T00:00:00.000Z",
  "updated_at": "2026-09-04T00:00:00.000Z",
  "raw_key": "mk_live_..."
}
```

Save the `raw_key` as the module credential. It is shown once and is not returned by the credential list endpoint.

```bash theme={null}
export INGEST_KEY="mk_live_..."
export EVENT_BODY='{"type":"ticketing.ticket.purchased","version":1,"event_id":"evt-ticket-001","occurred_at":"2026-09-04T12:00:00.000Z","payload":{"ticket_id":"T-100","amount_minor":2500,"currency":"GBP"}}'
```

## 7. Sign and send an event

The signature is HMAC-SHA256 over the exact raw body prefixed with the Unix timestamp and a period. The body sent by `curl` must be byte-for-byte identical to the body used to compute the signature.

```bash theme={null}
export SIGNATURE="$(node -e 'const crypto = require("node:crypto"); const body = process.env.EVENT_BODY; const timestamp = Math.floor(Date.now() / 1000); const digest = crypto.createHmac("sha256", process.env.INGEST_KEY).update(`${timestamp}.${body}`).digest("hex"); process.stdout.write(`t=${timestamp},v1=${digest}`);')"

curl -X POST "$API_BASE_URL/v1/ingest" \
  -H "Authorization: Bearer $INGEST_KEY" \
  -H "X-4D-Signature: $SIGNATURE" \
  -H "Idempotency-Key: evt-ticket-001" \
  -H "Content-Type: application/json" \
  --data "$EVENT_BODY"
```

The receptor returns `202 Accepted` with the durable outcome:

```json theme={null}
{
  "inbound_event_id": "...",
  "status": "canonicalised",
  "canonical_event_id": "...",
  "reject_reason": null
}
```

The event's native type and payload must match the installed manifest. The receptor validates the native payload, applies the declared projection, and validates the resulting platform canonical payload before emitting it.

## What to remember

* Use an admin session to create, version, publish, install, and issue credentials.
* Use an `org_` key for server-side partner reads. Key management, like module authoring, needs the admin session.
* Use an `mk_live_` module credential, the HMAC signature, and the matching `Idempotency-Key` for `POST /v1/ingest`.
* A private module is visible and installable only to its owning organization.
