Invalid Refresh Token

Hi Guys,

I am hoping someone can help, I am having an issue with refreshing the access token. I feel I must be missing a step.

  1. I have setup the application and got my client_id and client_secret from

  2. I have logged into Keap ( and clicked API Access and used the client_id and client_secret to generate an initial access_token and refresh_token

  3. I have a cron running every 21 hours to fetch a new access_token and refresh_token using the refresh token as per the request.

I am getting the error Invalid Refresh Token I have not used it beforehand, but its still not working.



headers: "Authorization":"Basic **REMOVED**" "Content-Type":"application/x-www-form-urlencoded"


error":{"name":"Error","message":"Request failed with status code 400","response":{"status":400,"data":{"error":"invalid_request","error_description":"Invalid Refresh Token"}}}}

Any guidance would be appreciated



Good morning Andy!

Just to check, are you supplying the parameters form-urlencoded or as a querystring? Your POST body should look something like that, but it shouldn’t be in the URI.

@TomScott I tried both. I assume if it was missing the params I would get a different error?

I also tried directly from POSTMAN and I get the same error.

Full Note.js code:

            //is it time?
            const settings_keap_access = await Settings.readSetting({name: 'KEAP_ACCESS_TOKEN'});
            const settings_keap_refresh = await Settings.readSetting({name: 'KEAP_REFRESH_TOKEN'});
            if (moment(settings_keap_access.ext_next_check) < moment()) {

                const url = "";
                const data = qs.stringify({
                    client_id: process.env.KEAP_CLIENT_ID,
                    client_secret: process.env.KEAP_CLIENT_ID,
                    grant_type: "refresh_token",
                    refresh_token: settings_keap_refresh.value

                const config = {
                    headers: {
                        "Authorization": `Basic ${base64(process.env.KEAP_CLIENT_ID + ':' + process.env.KEAP_CLIENT_ID)}`,
                        'Content-Type': 'application/x-www-form-urlencoded'

                try {

                    const response = await, data, config)
                    if (response.status === 200) {

                        await Settings.updateSetting({value:}, {name: 'KEAP_REFRESH_TOKEN'});
                        await Settings.updateSetting({value:, ext_next_check: moment().add(21, 'hours').format(process.env.DATE_FORMAT)}, {name: 'KEAP_ACCESS_TOKEN'});
                        logger.log('success', 5, 'keap', `New Access Token: ${}`, {})
                        return true

                    } else {
                        logger.log('error', 9, 'keap', `Failed to refresh tokens`, {
                            url: url,
                            config: config,
                            response: {
                                status: response.status,

                        return false

                catch (e) {
                    logger.log('error', 9, 'keap', `Error updating access token`, {
                        url: url,
                        config: config,
                        error: {
                            name: ? : ' ',
                            message: e.message ? e.message : ' ',
                            response: e.response ? {
                                status: e.response.status ? e.response.status : ' ',
                                data: ? : ' ',
                            } : ' '
                    return false


It looks like your Authorization header has the CLIENT_ID twice, rather than including the CLIENT_SECRET

Thank you so much @TomScott seems to be working now. Sorry about that.

No worries, glad you are up and running! :grin:

I’m having the very same issue, but I don’t have the client_id used twice, but am getting the same error. I’m using POSTMAN and PHP curl/command line and still the same error response.

$curl = curl_init();
curl_setopt_array($curl, array(
'Content-Type: application/x-www-form-urlencoded',
$response = curl_exec($curl);
echo $response;


        "error": "invalid_request",
        "error_description": "Invalid Refresh Token"

The most common reason we see this when people are using PHP is the multithreaded nature of requests coming in to a webserver, compounded by asynchronous requests inside a thread to the token endpoint which consume the Refresh Token (since each can only be used a single time) and then are lost by a second call that fails. If this is happening repeatedly, I would recommend that you write to a logfile with the CURLOPT_POSTFIELDS and the $response to identify if you have things executing out-of-order.

error_log('Refreshing: %DEFINITELY_THE_LAST_TOKEN_GENERATED_A_FEW_DAYS_AGO% \n' . $response, 3, 'my/file/path/token_calls.txt');

But this is even happening with using POSTMAN. I’m going to translate this over to PHP eventually, just haven’t yet, but I know the example code I sent above would still work like normal.

Here’s the POSTMAN output, still something is not happening properly somewhere.


Looking at your images, I’m not sure that you are providing the required parameters in the proper format. Per Getting Started with OAuth2 - Keap Developer Portal you don’t provide a client_id and client_secret as form parameters:

The word “Basic ” (with a space) concatenated with a base64 encoded string of your client_id, a colon, and your client_secret passed via the Authorization header. Example pseudo code: Basic + base64_encode(CLIENT_ID + ':' + CLIENT_SECRET)

It looks like @Terry_Odneal is supplying the client credentials twice. Once with the Postman Basic Auth header and also in the form data. Also make sure you are not reusing the refresh token. Like @TomScott said they are single use.

Honestly I’m just trying to do what the docs have been telling me to do, which are [no offense] not very easy to follow.
If I can’t refresh a token, then what’s the correct method for initiating a new one, because that doesn’t work automatically either. However, how in the world do I create a new token when I first have to do the “Authorization Request” curl call, which I can’t figure out how to do automatically…?

  1. Manually setup a KEAP account.
  2. Manually setup a DEVELOPERS account.
  3. Setup a new API Key with app id, client id, and client secret value.
  4. Go to the KEAP/InfusionSoft API and “TRY” an API call, in which you have to authenticate your DEVELOPERS account, then grant permission for the app id that you setup for your account.
  5. Then go back to the API page and “TRY” your call, only there have I been able to get the ACCESS TOKEN from the JSON response query string back.
  6. Once you have this ACCESS TOKEN above, then you can use it within an automatic 3rd party application.
    …oh but guess what, it EXPIRES and can’t be used after a short period of time…

So…how in the world do I either:

  1. Refresh that ACCESS TOKEN that I already had automatically with a script…?
  2. Create a new ACCESS TOKEN automatically with a script…?

Maybe there’s an easier way to do all this…but I haven’t found one and no one via tech support can provide us with one.

Thank you.

Ok, I see the problem you are running into. There is a lot going on here that is off. For one you should not be registering for a developer account in order to get a 3rd party integration to work. That is their job. They are the ones that are supposed to be initiating the OAuth 2.0 flow. Your role in that process as a Keap app owner is just the authorization step where you login and grant their application access. No 3rd party application should ask you for a client_id, client_secret, access_token, refresh_token, etc. That is a violation of our terms. All they should be doing is having you click a link to authorize.

For the Try page it is not intended for you to get a token and use that in another system. The access token response returns a refresh token when you use to get another access token. We don’t expose the refresh token on the Try page.

If you are trying this via Postman and you do want to go through the process of registering for a developer account to either write your own integration or just access your own data then Postman has an OAuth 2.0 Authentication option where you set all the OAuth fields like client_id, client_secret, authorization_url, and token_url. It will then put you through an OAuth 2.0 Authorization Flow and return you a token response that includes the access_token, refresh_token, and an expires_in (which is currently 24 hours. When you want to refresh the token you can do so for up 45 days. When you do that you will get a brand new access_token and refresh_token which the access token is good for 24 hrs and the refresh token is good for another 45 days. This allows you to have an an unbounded access to the API as long as you don’t let the refresh token lapse. Postman will not automatically refresh token without some scripting inside of Postman.

Everything we do is according to the OAuth 2.0 spec and there are tons of tutorials online that are great, but it is a red flag that you are trying to put an access_token into a 3rd party application.

Ok then I need to do some digging because I don’t see anywhere in the KEAP/InfusionSoft API docs on how to generate an access_token (or a 3rd party app) that can be used for the API (which I’ve got working while using postman after manually getting an access_token?

The only way I can get that token right now is [like I said above], after clicking on the “Try the API”, putting in the client_id and client_secret, then “approving” the app again, then going back to a “Try” example and it provides me one in the popup response.

Do you have a link somewhere that I can provide to a potential 3rd party app integrator so they know what they need to even get the access_token generated to start getting this automated? I’m doing some further digging in the forums now.

The developer guide you referenced is the docs they need to follow. I also like to point developers to the official OAuth spec for Authorization Code Grants RFC 6749: The OAuth 2.0 Authorization Framework

Ok I must be missing something super obvious then. Maybe I can come at this in a different way…
When the system was InfusionSoft, integration could be done via a web form, something like

Can this still be done, and if so, how/where do I find this form in the Keap system so I can generate the fields and such?

I would not try to integrate directly to a webform. Like posting to the web form from a backend system. These are meant to be used by a real person in a browser. If you try to post from a backend system it will probably trigger our bot control because the requests will look like a bot.

It depends on what edition of Keap you have if you are just looking for those webforms. Not all editions support webforms and the automation around them. The old Infusionsoft is just rebranded to Keap Max Classic.

It is MAX that the account is on, and a webform would be best.

@Terry_Odneal ,

We can not guarantee support for a form being sent from a backend service. We use ReCaptcha to validate human interaction on forms as necessary to prevent malicious usage, and it will quickly block your attempts to access it.

If you’re working with a 3rd party developer, here’s the process:

  1. Sign up for a developer account (which it seems you’ve done) to generate a private key and secret that you will own.
  2. Provide the developer with that key and secret, and tell them to walk this process via CURL or PostMan: Getting Started with OAuth2 - Keap Developer Portal
    To be clear, this requires all of
    A) Making an Authorization request in a web browser to authorize a Code.
    B) Trading that Code for a Refresh Token and Access Token.
    C) Using the Access Token to make requests for the 24 hour window in which it is valid.
    D) Using the Refresh Token to create a new Refresh Token and Access Token and persisting them on a schedule daily via cron job as the Access Token expires and with a cron job that runs every ~30 days to make sure the Refresh Token doesn’t expire during a period of inactivity.
    If this will be a single integration for your system, you can use “http://localhost” or similar as the redirect_uri parameter in these requests to retrieve the Code and trade it in.
  3. Have the developer make calls against the API with the currently valid Access Token as necessary to complete your functionality.

Ok this is now getting ridiculous to figure out properly.

Step 2A - above must be done manually (I had to create a custom form just to have it send over the body values properly). That’s fine, I went through the step and I guess it worked.
Step 2B - didn’t work in Postman (it takes me to a “one more step” page, then does nothing, so I had to get my access token once again via the API docs page manually.
Step 2C - the token works for the time period, but I had to get it manually (which isn’t going to work).
Step 2D - this didn’t work through Postman either because it kept redirecting me to a “one more step” page, which meant I have to do it manually and not via cronjob.

I’m going to do some digging to see if anyone has posted their cronjob code on the forums somewhere because I can not [for the life of me] figure this new Keap API system out.