> For the complete documentation index, see [llms.txt](https://docs.gmgn.ai/index/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.gmgn.ai/index/gmgn-callout-openapi.md).

# GMGN Callout OpenAPI

The **GMGN Callout OpenAPI** lets approved partners programmatically publish and read on-chain "callouts" — a signal from a wallet that it is calling a specific token, optionally with a thesis and a linked Twitter/X identity.

Use it to:

* **Create callouts** — a wallet calls a token, attributed to a verified Twitter/X account.
* **Read a wallet's history** — paginate the callouts made by a single wallet.
* **Read a token's callouts** — the cross-wallet callout feed for one token, including the highest-multiplier callout.

All endpoints are `POST`, accept and return `application/json` (UTF-8), and are authenticated with an **AK/SK HMAC signature** on every request. Your `ak` / `sk` credentials are issued by GMGN.

|                  |                                                        |
| ---------------- | ------------------------------------------------------ |
| **Base URL**     | `https://papi.gmgn.ai/callout/openapi/v1`              |
| **Content-Type** | `application/json` (UTF-8)                             |
| **Auth**         | AK/SK HMAC-SHA256 signature (headers on every request) |

***

### Response envelope

Every response is wrapped in a `{code, message, data}` envelope. **Business data always lives under `data` — unwrap `data`; never read top-level fields directly.**

```json
{ "code": 0, "message": "", "data": { } }
```

| Situation               | Shape                                                                                                              |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `HTTP 200` + `code = 0` | Success. Business payload is in `data`. For write endpoints, whether the operation was accepted is `data.success`. |
| `HTTP 4xx / 5xx`        | `code` (int) + `message` (string) describe the error. No `data`.                                                   |

#### Two layers of outcome for writes

For `POST /create`, a transport-level `HTTP 200` does **not** mean the callout was accepted. There are two distinct layers:

1. **Transport / auth / limits** — HTTP status code (`400`, `401`, `429`, `500`).
2. **Business rule** — inside `data`, the boolean `data.success`. When `false`, inspect `data.reject.reason`.

#### Data types

* **Prices, market caps, multipliers, percentages, and token amounts are strings** (e.g. `"0.5"`, `"2.3"`) to preserve precision. Parse with a decimal-safe library, not native floats.
* **Timestamps are Unix milliseconds** (int64) unless a field is explicitly an ISO-8601 string (e.g. `created_at` on the `/token` feed).
* **`ulid`** is the unique identifier of a callout.

***

### Authentication

Every request must be signed with your **AK/SK** credentials using **HMAC-SHA256**. Keep the `sk` server-side — never ship it in a client app or browser.

#### Required headers

| Header        | Description                                       |
| ------------- | ------------------------------------------------- |
| `X-Ak`        | Your issued access key.                           |
| `X-Timestamp` | Current time as a **Unix millisecond** timestamp. |
| `X-Signature` | Lowercase hex HMAC-SHA256 of the payload below.   |

#### Signature payload

Concatenate these fields **in order, with no separators**:

```
payload = ak + timestamp + method + path + rawQuery + body
```

| Field       | Description                                                                                             |
| ----------- | ------------------------------------------------------------------------------------------------------- |
| `ak`        | Same value as `X-Ak`.                                                                                   |
| `timestamp` | Same value as `X-Timestamp` (millisecond string).                                                       |
| `method`    | Uppercase HTTP method, e.g. `POST`.                                                                     |
| `path`      | Request path **including the API prefix, excluding host and query**, e.g. `/callout/openapi/v1/create`. |
| `rawQuery`  | URL query string (empty string for these POST endpoints).                                               |
| `body`      | Raw request body — **must match the bytes actually sent** exactly. Empty string if there is no body.    |

```
signature = HEX( HMAC_SHA256(sk, payload) )   // lowercase hex
```

> ⚠️ **Timestamp window: ±30 seconds.** If `X-Timestamp` differs from server time by more than 30s the request is rejected. Keep your client clock synced (NTP).
>
> ℹ️ Because the signature covers the **raw body bytes**, sign the exact serialized JSON string you send. Do not re-serialize, pretty-print, or reorder keys between signing and sending.

#### Signing examples

<details>

<summary>Go</summary>

```go
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
method := "POST"
path := "/callout/openapi/v1/create"
rawQuery := ""
body := `{"chain":"sol","call_wallet":"...","call_token":"...","twitter_id":"1926272637581336576"}`

payload := ak + ts + method + path + rawQuery + body
mac := hmac.New(sha256.New, []byte(sk))
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
// Headers: X-Ak: ak, X-Timestamp: ts, X-Signature: sig
```

</details>

<details>

<summary>Python</summary>

```python
import hashlib, hmac, time, json, requests

AK, SK = "your_ak", "your_sk"
BASE = "https://papi.gmgn.ai"
path = "/callout/openapi/v1/create"
method = "POST"
raw_query = ""
body = json.dumps({
    "chain": "sol",
    "call_wallet": "...",
    "call_token": "...",
    "twitter_id": "1926272637581336576",
}, separators=(",", ":"))  # sign the exact bytes you send

ts = str(int(time.time() * 1000))
payload = AK + ts + method + path + raw_query + body
sig = hmac.new(SK.encode(), payload.encode(), hashlib.sha256).hexdigest()

resp = requests.post(
    BASE + path,
    data=body,  # send the same bytes that were signed
    headers={
        "Content-Type": "application/json",
        "X-Ak": AK,
        "X-Timestamp": ts,
        "X-Signature": sig,
    },
)
print(resp.json())
```

</details>

<details>

<summary>Node.js</summary>

```javascript
import crypto from "node:crypto";

const AK = "your_ak", SK = "your_sk";
const BASE = "https://papi.gmgn.ai";
const path = "/callout/openapi/v1/create";
const method = "POST";
const rawQuery = "";
const body = JSON.stringify({
  chain: "sol",
  call_wallet: "...",
  call_token: "...",
  twitter_id: "1926272637581336576",
}); // sign the exact string you send

const ts = Date.now().toString();
const payload = AK + ts + method + path + rawQuery + body;
const sig = crypto.createHmac("sha256", SK).update(payload).digest("hex");

const resp = await fetch(BASE + path, {
  method,
  headers: {
    "Content-Type": "application/json",
    "X-Ak": AK,
    "X-Timestamp": ts,
    "X-Signature": sig,
  },
  body,
});
console.log(await resp.json());
```

</details>

#### Authentication failures (HTTP 401)

```json
{ "code": 401, "message": "invalid signature" }
```

| `message`             | Meaning                                                  |
| --------------------- | -------------------------------------------------------- |
| `missing auth header` | One of `X-Ak` / `X-Timestamp` / `X-Signature` is absent. |
| `invalid timestamp`   | `X-Timestamp` is malformed or outside the ±30s window.   |
| `invalid ak`          | The access key is not recognized.                        |
| `invalid signature`   | The computed signature does not match.                   |
| `api not allowed`     | This `ak` is not permitted to call this endpoint.        |
| `ip not allowed`      | The request originated from a non-allowlisted IP.        |

***

### Rate limits

Limits are enforced **per `ak`**.

| Limit              | Quota                                  | Over-limit response                  | Applies to     |
| ------------------ | -------------------------------------- | ------------------------------------ | -------------- |
| Request rate (QPS) | Per your allocation (default ≈ 10 QPS) | `HTTP 429` `rate limit exceeded`     | All endpoints  |
| Callout 24h quota  | Per your allocation                    | `HTTP 429` `ak daily quota exceeded` | `/create` only |

* Read endpoints (`/get_record`, `/token`) **do not** consume the callout 24h quota. They are still subject to the QPS limit.
* The 24h callout quota is distinct from the per-`twitter_id` and per-wallet business limits, which surface as **business rejects** (`data.success = false`) rather than `HTTP 429` — see reject reasons.

***

### Endpoints

#### POST /create — Create a callout

Publish a callout: one on-chain wallet calling one token. The source platform is derived automatically from your `ak` — you do not pass it.

```
POST https://papi.gmgn.ai/callout/openapi/v1/create
```

**Request body**

| Field                | Type   | Required | Description                                                         |
| -------------------- | ------ | :------: | ------------------------------------------------------------------- |
| `chain`              | string |     ✅    | Chain, e.g. `sol` / `eth` / `bsc`.                                  |
| `call_wallet`        | string |     ✅    | On-chain wallet address making the callout.                         |
| `call_token`         | string |     ✅    | Token address being called.                                         |
| `twitter_id`         | string |     ✅    | Caller's Twitter/X `rest_id`. **Numeric-only string** (`^[0-9]+$`). |
| `twitter_handle`     | string |          | `@handle`, used as a display fallback.                              |
| `twitter_username`   | string |          | Display name, used as a display fallback.                           |
| `call_thesis`        | string |          | Callout rationale text. **Subject to content moderation.**          |
| `callback_to_chain`  | string |          | Follow-callout: chain of the original post being followed.          |
| `callback_to_wallet` | string |          | Follow-callout: wallet of the original post being followed.         |
| `callback_to_ulid`   | string |          | Follow-callout: ULID of the original post being followed.           |

> ℹ️ The three `callback_to_*` fields describe a **follow-callout** (calling in response to another callout). Supply all three together, or none.

**Success response** (`HTTP 200`, `data.success = true`)

```json
{
  "code": 0,
  "data": {
    "success": true,
    "record": {
      "chain": "sol",
      "call_wallet": "...",
      "call_token": "...",
      "call_price": "0.5",
      "call_mc": "500",
      "ulid": "01HF7...",
      "create_time": 1700000000000,
      "token_symbol": "TKN",
      "multiplier": "1"
    }
  }
}
```

`ulid` is the unique identifier of the callout.

**Business reject** (`HTTP 200`, `data.success = false`)

The request was well-formed and authenticated, but a business rule blocked it:

```json
{
  "code": 0,
  "data": {
    "success": false,
    "reject": {
      "code": 40002404,
      "reason": "cooldown",
      "next_available_at": 1700000600000,
      "interval_minutes": 10
    }
  }
}
```

> ⚠️ Branch on **`reason`** (stable semantic string), never on `code` (troubleshooting aid only).

**Reject reasons**

| `reason`                    | `code`   | Meaning                                                                                                        |
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `twitter_not_bound`         | 40002401 | The calling wallet has no bound Twitter/X account.                                                             |
| `twitter_not_verified`      | 40002406 | The bound Twitter/X account has not passed verification.                                                       |
| `content_violation`         | 40002407 | `call_thesis` hit the content-moderation filter.                                                               |
| `content_audit_unavailable` | 40002408 | Moderation service unavailable — fail-closed reject. Retry later.                                              |
| `holding_too_low`           | 40002403 | The wallet's holding of this token is below the threshold.                                                     |
| `cooldown`                  | 40002404 | Same wallet + same token is in a cooldown window. `next_available_at` (ms) and `interval_minutes` describe it. |
| `twitter_daily_limit`       | 40002409 | This `twitter_id` exceeded its 24h callout count. `next_available_at` is the window reset time.                |
| `daily_limit`               | 40002405 | 24h callout count exceeded. `next_available_at` is the window reset time.                                      |

**`reject` fields**

| Field               | Type   | Description                                                                                            |
| ------------------- | ------ | ------------------------------------------------------------------------------------------------------ |
| `reason`            | string | Semantic reject reason (use this).                                                                     |
| `code`              | int    | Numeric business code (troubleshooting only).                                                          |
| `next_available_at` | int64  | For time-based rejects (`cooldown`, `daily_limit`, `twitter_daily_limit`): next allowed time, Unix ms. |
| `interval_minutes`  | int64  | For `cooldown`: length of the cooldown window in minutes.                                              |

> ℹ️ **Token safety checks (honeypot, etc.) are currently disabled.** They were previously a hard block, but new pools lacking safety data caused false rejections, so the hard block was removed in favor of a disclaimer approach. No safety-related `reason` is returned today — you do not need a handler branch for it. This may be reinstated later with separate notice.

**Invalid parameters** (`HTTP 400`)

```json
{ "code": 400, "message": "invalid twitter_id: must be a numeric rest_id" }
```

Missing or non-numeric `twitter_id` returns the above; other missing required fields return a corresponding `400`.

***

#### POST /get\_record — Callouts by wallet

Return the callout history of a single wallet, newest first, with a cursor that pages toward older records. Read endpoint — does **not** consume the 24h quota.

```
POST https://papi.gmgn.ai/callout/openapi/v1/get_record
```

**Request body**

| Field         | Type   | Required | Description                                                                             |
| ------------- | ------ | :------: | --------------------------------------------------------------------------------------- |
| `chain`       | string |     ✅    | Chain.                                                                                  |
| `call_wallet` | string |     ✅    | Wallet address to query.                                                                |
| `limit`       | int    |          | Page size. Max **50**.                                                                  |
| `page_token`  | string |          | Pagination cursor (`next_page_token` from the previous page). Empty for the first page. |

**Success response**

```json
{
  "code": 0,
  "data": {
    "records": [
      {
        "chain": "sol",
        "call_wallet": "...",
        "call_token": "...",
        "call_price": "0.5",
        "call_mc": "500",
        "ulid": "01HF7...",
        "multiplier": "2.3",
        "token_symbol": "TKN",
        "token_logo": "...",
        "twitter_username": "...",
        "holding_percentage": "0.12"
      }
    ],
    "next_page_token": "01HF6..."
  }
}
```

**`records[]` fields**

| Field                | Type   | Description                                          |
| -------------------- | ------ | ---------------------------------------------------- |
| `chain`              | string | Chain.                                               |
| `call_wallet`        | string | Wallet that made the callout.                        |
| `call_token`         | string | Token address called.                                |
| `call_price`         | string | Token price at callout time.                         |
| `call_mc`            | string | Market cap at callout time.                          |
| `ulid`               | string | Callout unique identifier.                           |
| `multiplier`         | string | Current multiplier vs. callout price (e.g. `"2.3"`). |
| `token_symbol`       | string | Token symbol.                                        |
| `token_logo`         | string | Token logo URL.                                      |
| `twitter_username`   | string | Caller display name.                                 |
| `holding_percentage` | string | Caller's holding percentage of the token.            |

**Pagination** — pass `page_token` = the previous response's `next_page_token` to fetch the next (older) page. An **empty `next_page_token`** means there are no older records.

> ℹ️ Unlike `/token`, `/get_record` reflects the **real callout source** per `call_wallet`. If you need to distinguish which platform a callout came from, use this endpoint.

***

#### POST /token — Callouts by token

Return the cross-wallet callout feed for one token: all callouts on that token, the highest-multiplier callout (`top_message`), and a pagination cursor. Read endpoint — does **not** consume the 24h quota.

```
POST https://papi.gmgn.ai/callout/openapi/v1/token
```

**Request body**

| Field        | Type   | Required | Description                                           |
| ------------ | ------ | :------: | ----------------------------------------------------- |
| `chain`      | string |     ✅    | Chain.                                                |
| `call_token` | string |     ✅    | Token address.                                        |
| `cursor`     | string |          | Pagination cursor (base64). Empty for the first page. |
| `limit`      | int    |          | Page size.                                            |

**Success response**

```json
{
  "code": 0,
  "data": {
    "chain": "sol",
    "community": { "token_address": "..." },
    "messages": [
      {
        "id": "gmgn_01HF7...",
        "content": "...",
        "username": "...",
        "display_name": "...",
        "profile_image_url": "...",
        "wallet_address": "...",
        "user_twitter_url": "...",
        "follower_count": 0,
        "created_at": "2026-07-14T00:00:00Z",
        "source": "gmgn",
        "multiplier": "2.3",
        "ulid": "01HF7..."
      }
    ],
    "has_more": true,
    "top_message": { },
    "next_cursor": "..."
  }
}
```

**`messages[]` fields**

| Field               | Type          | Description                        |
| ------------------- | ------------- | ---------------------------------- |
| `id`                | string        | Message identifier.                |
| `content`           | string        | Callout thesis / text.             |
| `media_url`         | string / null | Attached media, if any.            |
| `username`          | string        | Caller handle.                     |
| `display_name`      | string        | Caller display name.               |
| `profile_image_url` | string / null | Caller avatar URL.                 |
| `wallet_address`    | string        | Caller wallet address.             |
| `user_twitter_url`  | string        | Caller Twitter/X profile URL.      |
| `follower_count`    | int64         | Caller follower count.             |
| `created_at`        | string        | ISO-8601 timestamp of the callout. |
| `source`            | string / null | Callout source — see note below.   |
| `multiplier`        | string        | Multiplier vs. callout price.      |
| `ulid`              | string        | Callout unique identifier.         |

**`top_message`** — the highest-multiplier callout for this token **in the last 30 days**. Returned **only on the first page** (empty `cursor`). If there is none, the field is omitted.

**Pagination**

* `has_more` (bool) is the authoritative signal for whether to keep paging.
* `next_cursor` is returned **only when `has_more` is `true`**. When there are no more pages the field is **omitted entirely** (not an empty string or `null`).

> ⚠️ **Page on `has_more`, not on the presence of `next_cursor`.**
>
> ℹ️ On `/token`, `source` is **always `gmgn`** — this aggregated token-page view does not pass through third-party source identifiers. To distinguish callouts by originating platform, use `/get_record`, which reflects the real source per `call_wallet`.

***

### HTTP status codes

| Status | Meaning                                                       |
| ------ | ------------------------------------------------------------- |
| `200`  | Request handled. For writes, inspect `data.success`.          |
| `400`  | Invalid parameters (missing/malformed required field).        |
| `401`  | Signature authentication failed. See Authentication failures. |
| `429`  | Rate limit or 24h quota exceeded. See Rate limits.            |
| `500`  | Internal error. Safe to retry with backoff.                   |

***

### OpenAPI 3.0 specification

The complete OAS 3.0 definition below can be imported directly into Swagger UI, Redoc, or Postman.

> ℹ️ The `X-Signature` value must be **computed per request** — it cannot be a static value in Swagger/Postman. Use a pre-request script to build the signature over `ak + timestamp + method + path + rawQuery + body`.

```yaml
openapi: 3.0.3
info:
  title: GMGN Callout OpenAPI
  version: 1.0.0
servers:
  - url: https://papi.gmgn.ai/callout/openapi/v1

components:
  schemas:
    Reject:
      type: object
      properties:
        code: { type: integer, description: "Business code (troubleshooting aid; branch on reason)" }
        reason: { type: string, description: "Reject reason (use this to branch)", example: cooldown }
        next_available_at: { type: integer, format: int64, description: "Next allowed time (Unix ms)" }
        interval_minutes: { type: integer, format: int64 }
    CallOutRecord:
      type: object
      properties:
        chain: { type: string }
        call_wallet: { type: string }
        call_token: { type: string }
        call_price: { type: string }
        call_mc: { type: string }
        ulid: { type: string }
        create_time: { type: integer, format: int64 }
        token_symbol: { type: string }
        token_logo: { type: string }
        holding_percentage: { type: string }
        multiplier: { type: string }
        twitter_username: { type: string }
    TokenCallOutMessage:
      type: object
      properties:
        id: { type: string }
        content: { type: string }
        media_url: { type: string, nullable: true }
        username: { type: string }
        display_name: { type: string }
        profile_image_url: { type: string, nullable: true }
        wallet_address: { type: string }
        user_twitter_url: { type: string }
        follower_count: { type: integer, format: int64 }
        created_at: { type: string }
        source: { type: string, nullable: true, description: "Always gmgn on /token; use /get_record to distinguish source" }
        multiplier: { type: string }
        ulid: { type: string }
  parameters:
    XAk:        { name: X-Ak,        in: header, required: true, schema: { type: string } }
    XTimestamp: { name: X-Timestamp, in: header, required: true, schema: { type: string }, description: "Unix millisecond timestamp (±30s window)" }
    XSignature: { name: X-Signature, in: header, required: true, schema: { type: string }, description: "hex(HMAC_SHA256(sk, ak+ts+method+path+rawQuery+body))" }

paths:
  /create:
    post:
      summary: Create a callout
      parameters:
        - $ref: '#/components/parameters/XAk'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chain, call_wallet, call_token, twitter_id]
              properties:
                chain: { type: string, example: sol }
                call_wallet: { type: string }
                call_token: { type: string }
                twitter_id: { type: string, pattern: '^[0-9]+$' }
                twitter_handle: { type: string }
                twitter_username: { type: string }
                call_thesis: { type: string }
                callback_to_chain: { type: string }
                callback_to_wallet: { type: string }
                callback_to_ulid: { type: string }
      responses:
        '200':
          description: Business outcome is in data.success
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: integer }
                  data:
                    type: object
                    properties:
                      success: { type: boolean }
                      record: { $ref: '#/components/schemas/CallOutRecord' }
                      reject: { $ref: '#/components/schemas/Reject' }
        '400': { description: Invalid parameters }
        '401': { description: Signature authentication failed }
        '429': { description: Rate limit or 24h quota exceeded }
        '500': { description: Internal error }
  /get_record:
    post:
      summary: Callout history by wallet
      parameters:
        - $ref: '#/components/parameters/XAk'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chain, call_wallet]
              properties:
                chain: { type: string }
                call_wallet: { type: string }
                limit: { type: integer, maximum: 50 }
                page_token: { type: string }
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: integer }
                  data:
                    type: object
                    properties:
                      records:
                        type: array
                        items: { $ref: '#/components/schemas/CallOutRecord' }
                      next_page_token: { type: string }
        '401': { description: Signature authentication failed }
        '429': { description: Rate limit }
        '500': { description: Internal error }
  /token:
    post:
      summary: Cross-wallet callout list by token
      parameters:
        - $ref: '#/components/parameters/XAk'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chain, call_token]
              properties:
                chain: { type: string }
                call_token: { type: string }
                cursor: { type: string }
                limit: { type: integer }
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: integer }
                  data:
                    type: object
                    properties:
                      chain: { type: string }
                      community:
                        type: object
                        properties:
                          token_address: { type: string }
                      messages:
                        type: array
                        items: { $ref: '#/components/schemas/TokenCallOutMessage' }
                      has_more: { type: boolean }
                      top_message: { $ref: '#/components/schemas/TokenCallOutMessage' }
                      next_cursor: { type: string }
        '401': { description: Signature authentication failed }
        '429': { description: Rate limit }
        '500': { description: Internal error }
```
