# Quickstart

**Goal:** make one authenticated call to your [site](/start-here/glossary/#site)'s Content API from your terminal.

**When you need this.** API access is authenticated by default; a site can opt into [anonymous public reads](/source-cms/authenticate/guide/#check-api-operations-and-public-access) for genuinely public content. You need credentials to:

- read content on a site that hasn't enabled public access (the default)
- create, update, or delete content, on any site ([writing content](/source-cms/content-api/writing-content/))
- read unpublished drafts, for example in preview flows
- guarantee fresh reads: anonymous responses are CDN-cached per URL, authenticated requests bypass that cache

## What you'll have when you're done

- An [API client](/start-here/glossary/#api-client) (client ID + secret) for your site.
- A `.env` file with the three canonical `DRUPAL_*` variable names every later step reuses.
- One successful authenticated request, made with the [Drupal API Client](/start-here/glossary/#drupal-api-client), plain JavaScript, or `curl`, that proves your credentials work.

## Prerequisites

- An Acquia account with **admin access** to a Source CMS site: step 1 needs the `API > API clients` menu in the site's admin UI, which editor-level accounts don't see (no site? a self-serve <a href="https://www.acquia.com/products/acquia-cloud-platform/trial" target="_blank" rel="noopener">Cloud Platform free trial</a> gets you building on Acquia the same day, though its Drupal 11 application is the Cloud Platform path, not a Source CMS site. For a Source CMS site, <a href="https://www.acquia.com/request-a-demo/acquia-source" target="_blank" rel="noopener">request a Source demo</a> or see [other ways to get one](/start-here/what-you-can-build/#before-you-start))
- [Node.js](https://nodejs.org/) 20.6 or later for the JSON:API Client and JavaScript paths, or [`curl`](https://curl.se/) (preinstalled on macOS and Linux)

## Steps

<Steps>

1. ### Create an API client

   In your site's admin UI, go to `API > API clients` and select `Add API client`. Give it a name (for example `local-dev`) and grant it the [scope](/start-here/glossary/#scope) `content:administer`: the scope that governs content over the API. Scopes are granted here, on the client; the token request in step 3 doesn't name any.

   Copy the **client ID** and **client secret** shown. The secret is displayed once; store it now.

2. ### Put the credentials in a `.env` file

   These three variable names are the canon; the get-content and deploy guides use them unchanged:

   ```bash
   # .env
   DRUPAL_SITE_URL=https://your-site.example.com
   DRUPAL_CLIENT_ID=9f8e7d6c-0000-0000-0000-000000000000
   DRUPAL_CLIENT_SECRET=your-client-secret
   ```

   `DRUPAL_SITE_URL` is the address you use to reach the site in a browser, scheme included, with no path and no trailing slash. Add `.env` to `.gitignore` if it isn't there already.

3. ### Make one authenticated request

   The credentials buy an OAuth 2.0 access token via the `client_credentials` grant, and the token authenticates a call to the [JSON:API](/start-here/glossary/#jsonapi) root. Pick how you want to do the exchange; your choice stays selected as you move between pages:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       The [Drupal API Client](/start-here/glossary/#drupal-api-client) does the token exchange for you: it requests a token from your credentials on first call and refreshes it when it expires. Install it, then save and run this script:

       ```bash
       npm install @drupal-api-client/json-api-client
       ```

       ```js
       // auth.mjs
       import { JsonApiClient } from
         "@drupal-api-client/json-api-client";

       const client = new JsonApiClient(
         process.env.DRUPAL_SITE_URL,
         {
           apiPrefix: "api", // Source CMS serves JSON:API at /api
           authentication: {
             type: "OAuth",
             credentials: {
               grantType: "client_credentials",
               clientId: process.env.DRUPAL_CLIENT_ID,
               clientSecret: process.env.DRUPAL_CLIENT_SECRET,
             },
           },
         },
       );

       const { response, error } = await client.fetch(
         `${process.env.DRUPAL_SITE_URL}/api`,
         { headers: { Accept: "application/vnd.api+json" } },
       );
       // response is null whenever error is set.
       if (error) throw error;
       console.log(response.status);
       console.log(JSON.stringify(await response.json(), null, 2));
       ```

       ```bash
       node --env-file=.env auth.mjs
       ```

       There is no token step: the client negotiated one behind the scenes and will keep doing so as tokens expire. `client.fetch` resolves to a response *or* an error, never both, so the `error` check is what turns an unreachable `DRUPAL_SITE_URL` into the real `fetch failed` / `ENOTFOUND` message instead of `TypeError: Cannot read properties of null (reading 'status')`.
     </TabItem>
     <TabItem label="JavaScript">
       Exchange the credentials for a token with a `POST` to `/oauth/token`, then send the token in an `Authorization: Bearer` header. Save and run this script:

       ```js
       // auth.mjs
       const {
         DRUPAL_SITE_URL,
         DRUPAL_CLIENT_ID,
         DRUPAL_CLIENT_SECRET,
       } = process.env;

       const tokenResponse = await fetch(
         `${DRUPAL_SITE_URL}/oauth/token`,
         {
           method: "POST",
           headers: {
             "Content-Type": "application/x-www-form-urlencoded",
           },
           body: new URLSearchParams({
             grant_type: "client_credentials",
             client_id: DRUPAL_CLIENT_ID,
             client_secret: DRUPAL_CLIENT_SECRET,
           }),
         },
       );
       const { access_token, expires_in } =
         await tokenResponse.json();
       console.log(`token expires in ${expires_in}s`);

       const response = await fetch(`${DRUPAL_SITE_URL}/api`, {
         headers: {
           Accept: "application/vnd.api+json",
           Authorization: `Bearer ${access_token}`,
         },
       });
       console.log(response.status);
       console.log(JSON.stringify(await response.json(), null, 2));
       ```

       ```bash
       node --env-file=.env auth.mjs
       ```

       The first line printed is `token expires in 300s`: your token, alive for 5 minutes.
     </TabItem>
     <TabItem label="curl">
       Load the file into your shell and exchange the credentials for a token:

       ```bash
       set -a; source .env; set +a  # export every variable in .env into this shell

       curl -s -X POST "$DRUPAL_SITE_URL/oauth/token" \
         -H "Content-Type: application/x-www-form-urlencoded" \
         -d "grant_type=client_credentials" \
         -d "client_id=$DRUPAL_CLIENT_ID" \
         -d "client_secret=$DRUPAL_CLIENT_SECRET"
       ```

       A successful response looks like this (the API returns it as one compact line; shown formatted):

       ```json
       {
         "token_type": "Bearer",
         "expires_in": 300,
         "access_token":
           "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9…(redacted)"
       }
       ```

       Store the token and call the API root:

       ```bash
       TOKEN=$(curl -s -X POST "$DRUPAL_SITE_URL/oauth/token" \
         -H "Content-Type: application/x-www-form-urlencoded" \
         -d "grant_type=client_credentials" \
         -d "client_id=$DRUPAL_CLIENT_ID" \
         -d "client_secret=$DRUPAL_CLIENT_SECRET" \
         | sed -n 's/.*"access_token": *"\([^"]*\)".*/\1/p')

       curl -s "$DRUPAL_SITE_URL/api" \
         -H "Accept: application/vnd.api+json" \
         -H "Authorization: Bearer $TOKEN"
       ```
     </TabItem>
   </Tabs>

   Whichever path you took, a `200` response returns JSON listing your site's available resource endpoints; a real site lists fifteen-plus `links` (files, media types, taxonomies, menus) around the ones you care about, which start with `node--` (shown trimmed and formatted):

   ```json
   {
     "jsonapi": {
       "version": "1.1",
       "meta": {
         "links": {
           "self": {
             "href": "http://jsonapi.org/format/1.1/"
           }
         }
       }
     },
     "data": [],
     "meta": {
       "links": {
         "me": {
           "meta": {
             "id": "cbad0cdf-0000-0000-0000-000000000000"
           }
         }
       }
     },
     "links": {
       "file--file": {
         "href": "https://your-site.example.com/api/file/file"
       },
       "media--image": {
         "href": "https://your-site.example.com/api/media/image"
       },
       "node--article": {
         "href": "https://your-site.example.com/api/node/article"
       },
       "node--page": {
         "href": "https://your-site.example.com/api/node/page"
       },
       "…": "…"
     }
   }
   ```

   Two proofs the token worked, whatever your site's settings:

   - On a site whose `Public access` setting (under `API > JSON:API`) is `No`, the same request without the `Authorization` header (or with a bad token) returns `401 Unauthorized`; the token is what turns the `401` into a `200`.
   - With `Public access` set to `Yes`, reads succeed even without a token, but the `meta.links.me` block above appears **only** on authenticated requests. If you see it, your token was used.

   The [auth guide](/source-cms/authenticate/guide/) covers the public-access toggle and its security trade-off.

</Steps>

## What just happened

Your site issued you an OAuth 2.0 access token in exchange for the API client's ID and secret: the `client_credentials` grant, made for server-to-server calls where no user is present. The request named no scopes; the token automatically gets the ones you selected on the client in step 1 (`content:administer`). Every Content API request carries that token in an `Authorization: Bearer <token>` header. Tokens live 300 seconds (5 minutes; see `expires_in`). The [Drupal API Client](/start-here/glossary/#drupal-api-client) requests and refreshes tokens for you; in the JavaScript and curl paths your code requests a new one when the old one expires.

## When something goes wrong

**`{"error":"invalid_client","error_description":"Client authentication failed"}` (401 from `/oauth/token`)** means the client ID or secret is wrong. Re-copy both from `API > API clients`; if the secret is lost, create a new client, since the secret is shown only once.

**`401 Unauthorized` on the API call itself** means the token is missing, malformed, or expired. Check the header is exactly `Authorization: Bearer <token>` and request a fresh token; they expire after `expires_in` seconds. More cases in the [auth guide's troubleshooting](/source-cms/authenticate/guide/#when-something-goes-wrong).

**``{"error":"invalid_scope","error_description":"The requested scope is invalid, unknown, or malformed","hint":"Check the `content:read` scope"}`` (400 from `/oauth/token`)** means the token request named a scope that doesn't exist or isn't selected on the client. The commands above send no `scope` parameter at all (the token automatically gets the client's selected scopes), so if you added one, remove it.

**`403 Forbidden`** means the token is valid but the client lacks the scope the request needs. Scopes are granted on the client, not in the token request: confirm `content:administer` is selected at `API > API clients`, then request a fresh token. A client without `content:administer` can't write content, and with `Public access` set to `Yes`, reads succeed even without a token, so a missing scope typically surfaces on writes.

**`SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON`** from the JSON:API Client means it called the wrong base path and got an HTML page back: the client's default is `/jsonapi`, but Source CMS serves the API at `/api`. Construct the client with `apiPrefix: "api"`, as in step 3.

**`curl: (3) URL rejected` or a request to `https:///oauth/token`** means the env vars never loaded. Run `set -a; source .env; set +a` in the same shell, then `echo $DRUPAL_SITE_URL` to confirm. In the Node.js paths the same mistake surfaces as `TypeError: Failed to parse URL from undefined/oauth/token` (JavaScript) or `Error: baseUrl is required` (JSON:API Client); run the script with `--env-file=.env` from the directory holding `.env`.

## Next steps

- [Query content by type](/source-cms/content-api/quickstart/): use this token to fetch real entries.
- [First fetch](/source-cms/content-api/first-fetch/): your `.env` file works there unchanged.
- [Authentication & API credentials guide](/source-cms/authenticate/guide/): grant types, scopes, storage, and rotation for real projects.
