Documentation

Rewarding a Google Form Submission

Issue a certificate to a Davi wallet when somebody submits a Google Form, using Apps Script and no server.

This guide issues a certificate to a respondent's Davi wallet when they submit your Google Form. The whole integration is a Google Apps Script attached to the form, with no server to run. You need to be an owner or manager of the organization.

The same approach works for any unattended automation where something outside Davi should cause a reward.

How the script gets a token

Redeeming a reward acts on behalf of an organization, so it needs an organization-scoped token. Nobody is signed in when a form is submitted:

  • client_credentials does not work. A token issued to your application alone carries no organization context, and the endpoints that need one refuse it.
  • Token exchange needs a user token, and a user token needs somebody to sign in.

So you authorize once, by hand, store the refresh token, and do the rest on each submission:

one time     you → sign in → authorization code → refresh token   (stored)
per submit   refresh token → user access token
             user access token → exchange → org-scoped token
             org-scoped token → redeem

1. Build the reward

The script issues an existing reward template. Create it in your organization's dashboard:

  1. Rewards → Add reward.
  2. Set the name, description, points and supply.
  3. Leave content storage on Platform, so Davi stores the content.
  4. Add item → Certificate, and fill in its title, description and image.
  5. Publish it. A draft or archived reward is refused at redemption.

The dashboard URL you end on contains both slugs the script needs:

/dashboard/orgs/<ORG_SLUG>/rewards/<REWARD_SLUG>

2. Ask the form for a Davi username

Davi resolves a recipient from a Davi identifier such as a username, a card or a wallet. An email address is not one of them. Add a short-answer question titled "Your Davi Username" and read the answer on submission.

A respondent can mistype their username. The script skips those responses instead of failing.

3. Add the script

In the form, open Extensions → Apps Script, replace the contents of Code.gs with the script below, and save. The script is bound to the form, so it can install its own submit trigger.

const OAUTH_SCOPES = 'org:read reward:read reward:redeem';
const USERNAME_QUESTION = 'Your Davi Username';

const PROP_REFRESH_TOKEN = 'DAVI_REFRESH_TOKEN';
const PROP_PKCE_VERIFIER = 'DAVI_PKCE_VERIFIER';
const PROP_ORG_UUID = 'DAVI_ORG_UUID';
const CACHE_USER_TOKEN = 'davi_user_access_token';
const CACHE_ORG_TOKEN = 'davi_org_access_token';

// ---------- Configuration ----------

function getConfig_() {
  const props = PropertiesService.getScriptProperties();
  const read = (key) => {
    const value = props.getProperty(key);
    if (!value) throw new Error('Script property ' + key + ' is not set.');
    return value.replace(/\/+$/, '');
  };
  return {
    apiBaseUrl: read('DAVI_API_BASE_URL'),
    appBaseUrl: read('DAVI_APP_BASE_URL'),
    clientId: read('DAVI_CLIENT_ID'),
    clientSecret: read('DAVI_CLIENT_SECRET'),
    orgSlug: read('DAVI_ORG_SLUG'),
    rewardSlug: read('DAVI_REWARD_SLUG'),
  };
}

function getRedirectUri() {
  const uri = 'https://script.google.com/macros/d/' +
    ScriptApp.getScriptId() + '/usercallback';
  console.log(uri);
  return uri;
}

// ---------- One-time authorization ----------

function authorize() {
  const config = getConfig_();
  const verifier = createCodeVerifier_();
  PropertiesService.getScriptProperties().setProperty(PROP_PKCE_VERIFIER, verifier);

  // Apps Script routes the redirect back to authCallback through this token.
  const state = ScriptApp.newStateToken()
    .withMethod('authCallback')
    .withTimeout(600)
    .createToken();

  const url = config.appBaseUrl + '/oauth/authorize?' + encodeQuery_({
    response_type: 'code',
    client_id: config.clientId,
    redirect_uri: getRedirectUri(),
    scope: OAUTH_SCOPES,
    state: state,
    code_challenge: codeChallenge_(verifier),
    code_challenge_method: 'S256',
  });
  console.log('Open this URL, signed in to Davi:\n' + url);
}

function authCallback(request) {
  const params = request.parameter;
  if (params.error) {
    return HtmlService.createHtmlOutput(
      'Davi refused the authorization: ' + params.error);
  }

  const config = getConfig_();
  const props = PropertiesService.getScriptProperties();
  const tokens = postToken_(config, {
    grant_type: 'authorization_code',
    code: params.code,
    redirect_uri: getRedirectUri(),
    code_verifier: props.getProperty(PROP_PKCE_VERIFIER),
  });

  props.setProperty(PROP_REFRESH_TOKEN, tokens.refresh_token);
  props.deleteProperty(PROP_PKCE_VERIFIER);
  cacheToken_(CACHE_USER_TOKEN, tokens);
  CacheService.getScriptCache().remove(CACHE_ORG_TOKEN);

  return HtmlService.createHtmlOutput('Connected to Davi. You can close this tab.');
}

// ---------- Tokens ----------

function getUserAccessToken_() {
  const cache = CacheService.getScriptCache();
  const cached = cache.get(CACHE_USER_TOKEN);
  if (cached) return cached;

  const lock = LockService.getScriptLock();
  lock.waitLock(30000);
  try {
    // Whoever held the lock may have refreshed already, retiring the
    // refresh token this run read before waiting.
    const refreshed = cache.get(CACHE_USER_TOKEN);
    if (refreshed) return refreshed;

    const props = PropertiesService.getScriptProperties();
    const refreshToken = props.getProperty(PROP_REFRESH_TOKEN);
    if (!refreshToken) {
      throw new Error('Not connected to Davi. Run authorize() first.');
    }

    let tokens;
    try {
      tokens = postToken_(getConfig_(), {
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
      });
    } catch (err) {
      throw new Error(err.message + ' (run authorize() to reconnect)');
    }
    props.setProperty(PROP_REFRESH_TOKEN, tokens.refresh_token);
    return cacheToken_(CACHE_USER_TOKEN, tokens);
  } finally {
    lock.releaseLock();
  }
}

function getOrgAccessToken_() {
  const cache = CacheService.getScriptCache();
  const cached = cache.get(CACHE_ORG_TOKEN);
  if (cached) return cached;

  const tokens = postToken_(getConfig_(), {
    grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
    subject_token: getUserAccessToken_(),
    subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
    resource: 'org:' + getOrganizationUuid_(),
  });
  return cacheToken_(CACHE_ORG_TOKEN, tokens);
}

function postToken_(config, form) {
  const response = UrlFetchApp.fetch(config.apiBaseUrl + '/oauth2/token', {
    method: 'post',
    headers: {
      Authorization: 'Basic ' +
        Utilities.base64Encode(config.clientId + ':' + config.clientSecret),
    },
    payload: form,
    muteHttpExceptions: true,
  });
  const body = parseJson_(response.getContentText());
  if (response.getResponseCode() !== 200) {
    const reason = body.error_description || body.error || body.message ||
      response.getContentText();
    throw new Error('Token request (' + form.grant_type + ') failed: ' + reason);
  }
  return body;
}

function cacheToken_(key, tokens) {
  // Expire the cached copy a minute early; CacheService holds at most 6 hours.
  const seconds = Math.min(Math.max((tokens.expires_in || 0) - 60, 1), 21600);
  CacheService.getScriptCache().put(key, tokens.access_token, seconds);
  return tokens.access_token;
}

// ---------- API ----------

function api_(method, path, token, body) {
  const options = {
    method: method,
    headers: { Authorization: 'Bearer ' + token },
    muteHttpExceptions: true,
  };
  if (body !== undefined) {
    options.contentType = 'application/json';
    options.payload = JSON.stringify(body);
  }
  const response = UrlFetchApp.fetch(getConfig_().apiBaseUrl + '/api/v1' + path, options);
  const status = response.getResponseCode();
  const data = parseJson_(response.getContentText());
  if (status >= 400) {
    const err = new Error(method.toUpperCase() + ' ' + path + ' failed with ' +
      status + ': ' + (data.message || response.getContentText()));
    err.statusCode = status;
    err.apiCode = data.code;
    throw err;
  }
  return data;
}

function getOrganizationUuid_() {
  const props = PropertiesService.getScriptProperties();
  const known = props.getProperty(PROP_ORG_UUID);
  if (known) return known;

  const org = api_('get', '/organizations/' + encodeURIComponent(getConfig_().orgSlug),
    getUserAccessToken_());
  props.setProperty(PROP_ORG_UUID, org.uuid);
  return org.uuid;
}

function getRewardTemplate_() {
  return api_('get', '/rewards/' + encodeURIComponent(getConfig_().rewardSlug),
    getOrgAccessToken_());
}

function redeemCertificate_(username, idempotencyKey, metadata) {
  return api_('post',
    '/rewards/' + encodeURIComponent(getConfig_().rewardSlug) + '/redeem',
    getOrgAccessToken_(),
    {
      identifier: { type: 'username', value: username },
      scope: 'single-use',
      idempotency_key: idempotencyKey,
      metadata: metadata,
    });
}

// ---------- Setup checks ----------

function checkConnection() {
  getUserAccessToken_();                    // refresh works
  const orgUuid = getOrganizationUuid_();   // org resolves
  getOrgAccessToken_();                     // exchange works
  const reward = getRewardTemplate_();      // reward is reachable

  console.log('Organization %s, reward "%s" (%s).', orgUuid, reward.name, reward.status);
  if (reward.status !== 'published') {
    console.warn('A %s reward cannot be redeemed. Publish it first.', reward.status);
  }
  const content = (reward.content_config || {}).content_template || {};
  const hasCertificate = (content.items || []).some((item) => item.type === 'certificate');
  if (!hasCertificate) {
    console.warn('The reward has no certificate item.');
  }
}

function installTrigger() {
  const form = FormApp.getActiveForm();
  ScriptApp.getProjectTriggers()
    .filter((trigger) => trigger.getHandlerFunction() === 'onFormSubmit')
    .forEach((trigger) => ScriptApp.deleteTrigger(trigger));
  ScriptApp.newTrigger('onFormSubmit').forForm(form).onFormSubmit().create();
}

// ---------- Form submission ----------

function onFormSubmit(e) {
  const formResponse = e.response;
  const responseId = formResponse.getId();

  const username = findUsername_(formResponse);
  if (!username) {
    console.warn('Response %s has no Davi username. Skipped.', responseId);
    return;
  }

  try {
    const result = redeemCertificate_(username, idempotencyKeyFor_(responseId), {
      source: 'google_forms',
      form_response_id: responseId,
      submitted_at: formResponse.getTimestamp().toISOString(),
    });
    console.log('Issued to @%s: %s', username, JSON.stringify(result));
  } catch (err) {
    if (err.apiCode === 'identifier_unresolved') {
      console.warn('No Davi account for "@%s". Skipped.', username);
      return;
    }
    throw err;
  }
}

function findUsername_(formResponse) {
  const answer = formResponse.getItemResponses().find(
    (itemResponse) => itemResponse.getItem().getTitle() === USERNAME_QUESTION);
  if (!answer) return null;
  const username = String(answer.getResponse()).trim().replace(/^@/, '');
  return username || null;
}

// ---------- Helpers ----------

function idempotencyKeyFor_(responseId) {
  // The API accepts at most 64 characters; a response id can be longer.
  return 'gforms-' + base64Url_(Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256, responseId, Utilities.Charset.UTF_8));
}

function createCodeVerifier_() {
  return (Utilities.getUuid() + Utilities.getUuid()).replace(/-/g, '');
}

function codeChallenge_(verifier) {
  return base64Url_(Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256, verifier, Utilities.Charset.US_ASCII));
}

function base64Url_(bytes) {
  return Utilities.base64EncodeWebSafe(bytes).replace(/=+$/, '');
}

function encodeQuery_(params) {
  return Object.keys(params)
    .map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(params[key]))
    .join('&');
}

function parseJson_(text) {
  try {
    return JSON.parse(text);
  } catch (err) {
    return {};
  }
}

Run getRedirectUri from the editor and copy the URL it logs. Apps Script asks you to grant the script access to the form, external requests and its own properties the first time you run anything.

4. Register the OAuth application

  1. Settings → Developer → OAuth apps → Create app, as a confidential client.
  2. Register the redirect URI you copied in step 3.
  3. Grant it the scopes org:read reward:read reward:redeem.
  4. Reopen it and enable token exchange. It is a separate grant, and the create dialog does not offer it. Without it, every run fails at the exchange step.

5. Configure the script

Set these in Project Settings → Script properties. The script adds its own entries there as it runs (the refresh token and the organization's UUID).

PropertyValue
DAVI_API_BASE_URLhttps://api.davi.social
DAVI_APP_BASE_URLhttps://davi.social
DAVI_CLIENT_IDFrom the OAuth app
DAVI_CLIENT_SECRETShown once on creation
DAVI_ORG_SLUGFrom the dashboard URL
DAVI_REWARD_SLUGFrom the dashboard URL

The app host shows the consent screen; the API host issues the tokens.

6. Authorize once, by hand

Run authorize and open the URL it logs, in a browser signed in to Google as the script's owner and to Davi as an owner or manager of the organization. After you approve the consent screen, Davi redirects to the script, which stores a refresh token and shows "Connected to Davi". No person is needed after this.

Run authorize again if the refresh token is ever revoked or expires unused. The script's errors say so when a refresh is refused.

7. Check the connection

Run checkConnection. It walks every hop in order, so the first one that is wrong is the one that throws: the refresh, the organization lookup, the exchange, and reading the reward back. It warns about the two setups that issue nothing: a reward that is not published, and one with no certificate item.

8. Install the trigger

Run installTrigger. It replaces any earlier submit trigger, so running it twice does not issue twice.

Test by submitting the form, not by pressing Run on onFormSubmit. The handler receives the form response as its argument, so running it from the editor passes nothing and fails.

How the script behaves

Tokens. Every refresh returns a new refresh token and retires the one sent. Presenting a retired one revokes the whole session. So getUserAccessToken_ caches the access token until a minute before it expires, takes the script lock before refreshing, and checks the cache again once it holds the lock: whoever held the lock may have just refreshed. The organization-scoped token is cached the same way, so a submission that finds both cached makes one request instead of three.

Repeats. The idempotency_key is derived from the form's response id. A submission has one response id, so a re-run after a timeout is recognized as the same redemption and does not issue a second certificate. The key is a hash because the API accepts at most 64 characters.

Metadata. The response id and timestamp go in metadata. Nothing reads them back automatically, but they record on the redemption why the certificate was issued.

Failures. Apps Script emails the owner whenever a trigger throws. The handler logs and skips a response with no username or an identifier_unresolved username, and lets every other error throw.

Asynchronous rewards. With content storage on Platform the redemption answers with a transaction_id. A reward on External storage answers with a delivery_uuid instead, and the certificate exists only once your endpoint has generated it.

Personalising the certificate

Read this if each certificate should name its holder. The steps above give everybody the same certificate.

Set the reward's content storage to External instead of Platform. On redemption Davi calls an endpoint you control; you build the certificate (with the recipient's name, their answers, your own serial) and return it, and Davi stores that copy immutably against the issuance. This needs an endpoint Davi can call.

Next