Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion concepts/security-portal.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Security Portal"
description: "Understanding your organization's public-facing threat reporting page"
description: "Configure your organization's public Security Portal at chainpatrol.io/<slug> — visibility, public reports, metrics, brand metadata, and customization."
---

## Overview
Expand Down Expand Up @@ -111,6 +111,18 @@ The report processing flow works as follows:

From there, the report goes through the same review process as any other submission.

## Reading Portal Configuration via the API

Each org's portal configuration is exposed on the [`POST /user/orgs`](/external-api/user-orgs-list) and [`GET /user/orgs/{slug}`](/external-api/user-orgs-get) endpoints (and on `UserOrg` in the JavaScript SDK). The response includes:

- `portalVisibility` — `PUBLIC`, `PRIVATE`, or `DISABLED`, mirroring the three states above.
- `reportVisibility` — `PUBLIC` or `PRIVATE`, matching the **Public Reports** toggle.
- `metricsVisibility` — `PUBLIC`, `PRIVATE`, or `DISABLED`, matching the **Public Metrics** toggle.
- `brandMetadata` — the public brand-facing fields shown on the portal (`websiteUrl`, `twitterHandle`, `securityContactEmail`, `industry`).
- `portalCustomization` — the customer-configurable presentation (`useCustomDetails`, custom `name` / `slug` / `avatarUrl`, and `bannerEnabled` / `bannerContent`).

Use these fields to audit portal setup across every org you administer without opening the dashboard.

## Key Takeaways

- Community reporting acts as an early warning system: Users often encounter threats before your internal team, so a public security portal captures intelligence from the front lines
Expand Down
7 changes: 7 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@
"external-api/organization-metrics",
"external-api/organization-metrics-list"
]
},
{
"group": "User",
"pages": [
"external-api/user-orgs-list",
"external-api/user-orgs-get"
]
}
]
},
Expand Down
106 changes: 106 additions & 0 deletions external-api/user-orgs-get.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
title: Get User Organization
openapi: "GET /user/orgs/{slug}"
---

## Overview

Fetch a single organization by slug. Returns the same per-organization shape as [`POST /user/orgs`](/external-api/user-orgs-list), including subscription status, enabled services, integrations, legal documents, trademark registrations, and the org's public [Security Portal](/concepts/security-portal) configuration.

Use this endpoint when you already know the target slug and want a single record instead of paging the full list.

Access requires the caller to have permission for the org:

- **Org-scoped API keys** must match the requested slug exactly.
- **User sessions and user-scoped API keys** need an active membership on the target org (unless the caller is staff).

Soft-deleted organizations are excluded.

## Portal, brand, and customization fields

The response includes the same portal-related fields as the list endpoint. See [`POST /user/orgs`](/external-api/user-orgs-list#portal-brand-and-customization-fields) for the full field reference and the [Security Portal concept](/concepts/security-portal) for what the visibility values mean end-to-end.

At a glance:

- `portalVisibility` (`PUBLIC` / `PRIVATE` / `DISABLED`) — whether the portal at `chainpatrol.io/<slug>` is discoverable, report-form-only, or 404s.
- `reportVisibility` (`PUBLIC` / `PRIVATE`) — whether the report list is shown on the portal.
- `metricsVisibility` (`PUBLIC` / `PRIVATE` / `DISABLED`) — whether the metrics tab is shown (and whether metrics are computed at all).
- `brandMetadata` — nullable `websiteUrl`, `twitterHandle`, `securityContactEmail`, `industry`.
- `portalCustomization` — `useCustomDetails`, custom `name` / `slug` / `avatarUrl`, and `bannerEnabled` / `bannerContent`.

## Example request

```bash cURL
curl -X GET 'https://app.chainpatrol.io/api/v2/user/orgs/acme' \
-H 'X-API-KEY: YOUR_API_KEY_HERE'
```

```typescript TypeScript
const res = await fetch(
"https://app.chainpatrol.io/api/v2/user/orgs/acme",
{
method: "GET",
headers: { "X-API-KEY": "YOUR_API_KEY_HERE" },
},
);

const org = await res.json();
console.log(org.portalVisibility, org.brandMetadata.securityContactEmail);
```

## Example response

```json
{
"id": 42,
"name": "Acme Protocol",
"slug": "acme",
"avatarUrl": "https://…/acme.png",
"subscriptionStatus": "ACTIVE",
"portalVisibility": "PRIVATE",
"reportVisibility": "PRIVATE",
"metricsVisibility": "PUBLIC",
"brandMetadata": {
"websiteUrl": "https://acme.example",
"twitterHandle": "@acme",
"securityContactEmail": "security@acme.example",
"industry": "DeFi"
},
"portalCustomization": {
"useCustomDetails": true,
"name": "Acme Security",
"slug": "acme-security",
"avatarUrl": "https://…/acme-security.png",
"bannerEnabled": false,
"bannerContent": null
},
"services": {
"reporting": { "active": true },
"reviewing": { "active": true },
"protection": { "active": true },
"takedowns": { "active": true, "automated": true },
"detection": { "active": true },
"darkWebMonitoring": { "active": false }
},
"obligatoryAdminApproval": true,
"integrations": {
"slack": true,
"discord": true,
"telegram": { "connected": false, "groupCount": 0 },
"vercel": false,
"intercom": false,
"moderation": false
},
"legalDocuments": {
"letterOfAuthorization": { "present": true, "fileName": "loa.pdf", "fileUrl": "https://…" },
"powerOfAttorney": { "present": true, "fileName": "poa.pdf", "fileUrl": "https://…" }
},
"trademarkRegistrations": []
}
```

## Notes

- The `slug` in the URL path must be the org's own slug. Custom portal slugs from `portalCustomization.slug` do not resolve here.
- The response is additive — clients that only read the pre-existing fields are unaffected by the portal, brand-metadata, and customization additions.
- To iterate every org the caller can access, use [`POST /user/orgs`](/external-api/user-orgs-list) instead.
189 changes: 189 additions & 0 deletions external-api/user-orgs-list.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
---
title: List User Organizations
openapi: "POST /user/orgs"
---

## Overview

List every organization the authenticated caller belongs to, along with each organization's subscription status, enabled services, integration connection status, legal documents, trademark registrations, and — most relevant to this page — the configuration for the org's public [Security Portal](/concepts/security-portal).

Use this endpoint to:

- Discover which organizations an API key or user session can act on.
- Audit portal visibility, brand-facing metadata, and portal customization across every org you administer.
- Filter the list server-side by subscription status, active services, or the "Obligatory Organization Admin Approval" setting.

The organization identity is inferred from your API key or user session. User API keys and user sessions return every org the user is a member of; org-scoped API keys return the single matching org. Soft-deleted and `INACTIVE` organizations are always excluded.

## Portal, brand, and customization fields

Each entry in the response describes how the org's public Security Portal at `chainpatrol.io/<slug>` is configured. See the [Security Portal concept](/concepts/security-portal) for the visibility model and where these values are edited in the dashboard.

### `portalVisibility`

Top-level visibility of the portal.

| Value | Behavior |
| ---------- | ----------------------------------------------------------------------------------------------- |
| `PUBLIC` | Portal is fully discoverable — metrics, listings, and the report form all render. |
| `PRIVATE` | Only the report form renders. Metrics and asset listings are hidden even to authenticated users. |
| `DISABLED` | `chainpatrol.io/<slug>` returns a 404. No portal is served. |

### `reportVisibility`

Whether the reports list on the portal is visible to visitors. `PUBLIC` shows the org's reports; `PRIVATE` hides the report list even when the portal itself is discoverable.

### `metricsVisibility`

Whether the metrics and threats-blocked stats render on the portal. `PUBLIC` shows the metrics tab; `PRIVATE` hides it from anonymous visitors; `DISABLED` means metrics are not computed for this org at all.

### `brandMetadata`

Public brand-facing metadata that renders on the portal header and is used by ChainPatrol staff and takedown providers for outbound contact. Every field is nullable — expect gaps for orgs still in onboarding.

| Field | Type | Description |
| ---------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------- |
| `websiteUrl` | `string \| null` | Public website URL of the brand. |
| `twitterHandle` | `string \| null` | Public Twitter / X handle, stored with the leading `@` (for example `@acme`). |
| `securityContactEmail` | `string \| null` | Email address for security correspondence, distinct from the internal customer POC email. |
| `industry` | `string \| null` | Free-form industry label (for example `"DeFi"`, `"CEX"`, `"NFT"`, `"Wallet"`). |

### `portalCustomization`

Customer-configurable presentation of the portal. When `useCustomDetails` is `false` the portal uses the org's own name, slug, and avatar; when `true` the fields below override them.

| Field | Type | Description |
| ------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `useCustomDetails` | `boolean` | When `true`, the portal uses `name`, `slug`, and `avatarUrl` from this object instead of the org's own values. |
| `name` | `string \| null` | Custom display name (used only when `useCustomDetails=true`). |
| `slug` | `string \| null` | Custom URL slug (`chainpatrol.io/<slug>`). Takes precedence over the org's own slug when set, regardless of `useCustomDetails`. |
| `avatarUrl` | `string \| null` | Custom avatar URL (used only when `useCustomDetails=true`). |
| `bannerEnabled` | `boolean` | When `true`, the portal renders `bannerContent` as a highlighted banner at the top of the page. |
| `bannerContent` | `string \| null` | Markdown body of the portal banner. Null or empty with `bannerEnabled=true` means the toggle is on but no content has been written yet. |

<Info>
A `bannerEnabled=true` with a null or empty `bannerContent` is a common audit finding — the banner is toggled on but never populated.
</Info>

## Example request

```bash cURL
curl -X POST 'https://app.chainpatrol.io/api/v2/user/orgs' \
-H 'X-API-KEY: YOUR_API_KEY_HERE' \
-H 'Content-Type: application/json' \
-d '{}'
```

## Example response

```json
{
"organizations": [
{
"id": 42,
"name": "Acme Protocol",
"slug": "acme",
"avatarUrl": "https://…/acme.png",
"subscriptionStatus": "ACTIVE",
"portalVisibility": "PUBLIC",
"reportVisibility": "PUBLIC",
"metricsVisibility": "PUBLIC",
"brandMetadata": {
"websiteUrl": "https://acme.example",
"twitterHandle": "@acme",
"securityContactEmail": "security@acme.example",
"industry": "DeFi"
},
"portalCustomization": {
"useCustomDetails": false,
"name": null,
"slug": null,
"avatarUrl": null,
"bannerEnabled": true,
"bannerContent": "Report suspicious Acme domains here — we act within 24h."
},
"services": {
"reporting": { "active": true },
"reviewing": { "active": true },
"protection": { "active": true },
"takedowns": { "active": true, "automated": false },
"detection": { "active": true },
"darkWebMonitoring": { "active": false }
},
"obligatoryAdminApproval": false,
"integrations": {
"slack": true,
"discord": false,
"telegram": { "connected": true, "groupCount": 2 },
"vercel": false,
"intercom": false,
"moderation": true
},
"legalDocuments": {
"letterOfAuthorization": { "present": true, "fileName": "loa.pdf", "fileUrl": "https://…" },
"powerOfAttorney": { "present": false, "fileName": null, "fileUrl": null }
},
"trademarkRegistrations": []
}
]
}
```

## Use cases

### Audit portal visibility across your orgs

Fetch every org you have access to and group them by portal visibility to answer "which customers have a public portal?" and "which portals are turned off?".

```typescript
const res = await fetch("https://app.chainpatrol.io/api/v2/user/orgs", {
method: "POST",
headers: {
"X-API-KEY": "YOUR_API_KEY_HERE",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});

const { organizations } = await res.json();

const byVisibility = organizations.reduce((acc, org) => {
(acc[org.portalVisibility] ??= []).push(org.slug);
return acc;
}, {} as Record<string, string[]>);

console.log(byVisibility);
// { PUBLIC: ["acme", "beta"], PRIVATE: ["gamma"], DISABLED: ["delta"] }
```

### Find orgs missing a security contact

`brandMetadata.securityContactEmail` is used by takedown providers and researchers, so surfacing empty values is a common quality check.

```typescript
const missing = organizations.filter(
(org) => !org.brandMetadata.securityContactEmail,
);

console.log(
"Orgs without a security contact:",
missing.map((o) => o.slug),
);
```

### Detect orgs with a banner toggled on but empty content

```typescript
const emptyBanner = organizations.filter(
(org) =>
org.portalCustomization.bannerEnabled &&
!org.portalCustomization.bannerContent?.trim(),
);
```

## Notes

- The response is additive — existing integrations that don't reference the new fields continue to work unchanged.
- `portalVisibility`, `reportVisibility`, and `metricsVisibility` are independent switches. A portal can be `PUBLIC` overall while individually hiding reports or metrics.
- `portalCustomization.slug` takes precedence over the org's own slug whenever it is set — even if `useCustomDetails` is `false`.
- Use [`GET /user/orgs/{slug}`](/external-api/user-orgs-get) to fetch a single org's record by slug when you already know which one you need.