SMS z Arkuszy Google: Apps Script, statusy i odpowiedzi
Sprawdzone z dokumentacją:
W Arkuszach Google wysyłasz SMS skryptem Apps Script: UrlFetchApp.fetch na https://smsportal.app/api/v1/gateway/send-bulk-sms z nagłówkiem x-api-key, po 50 wierszy w jednym żądaniu. Skrypt dodaje menu „SMS”, pomija wiersze już wysłane i wpisuje status obok numeru. Bez kodu: pobierz arkusz jako CSV i wgraj go w Wysyłce zbiorczej w panelu.
Czego potrzebujesz
- Arkusz Google z zakładką
SMS: A telefon, B imię, C treść, D status (pusta). - Konto smsportal z podłączonym telefonem z Androidem i kartą SIM.
- Klucz API z panelu smsportal (Klucze API → Wygeneruj klucz API).
- Do odbioru odpowiedzi: zakładka
Odpowiedzii wdrożenie skryptu jako aplikacji internetowej.
Jak wysłać SMS z Arkuszy Google
Przygotuj kolumny
Numer w kolumnie A, najlepiej jako tekst (
+48600100200), bo arkusz zamienia go na liczbę i gubi plus. Treść w kolumnie C może być formułą, np.="Dzień dobry "&B2&", przypominamy o wizycie jutro.". Wiersz z pustą treścią skrypt pomija.Zapisz klucz we właściwościach skryptu
Rozszerzenia → Apps Script, potem Ustawienia projektu → Właściwości skryptu → Dodaj właściwość skryptu: nazwa
SMSPORTAL_API_KEY, wartość to klucz. Klucz nie leży w kodzie, więc kopia arkusza go nie zdradzi.Wklej skrypt
W edytorze Apps Script zastąp zawartość pliku
Code.gskodem poniżej i zapisz. Skrypt wysyła 50 wierszy na żądanie i przy błędzie zatrzymuje się, wpisując jego treść w kolumnie D.Apps Script: menu SMS i wysyłka partiami// Wysyłka SMS z arkusza przez smsportal. // Klucz: Ustawienia projektu → Właściwości skryptu → SMSPORTAL_API_KEY. const SHEET_NAME = 'SMS'; const PHONE_COL = 1; // A: telefon const MESSAGE_COL = 3; // C: treść (może być formułą) const STATUS_COL = 4; // D: status, wpisuje go skrypt const BATCH_SIZE = 50; const COUNTRY_CODE = '48'; const SENT_PREFIX = 'W kolejce'; const API_URL = 'https://smsportal.app/api/v1/gateway/send-bulk-sms'; function onOpen() { SpreadsheetApp.getUi() .createMenu('SMS') .addItem('Wyślij niewysłane wiersze', 'sendPendingSms') .addToUi(); } function sendPendingSms() { const apiKey = PropertiesService.getScriptProperties().getProperty('SMSPORTAL_API_KEY'); if (!apiKey) throw new Error('Dodaj SMSPORTAL_API_KEY we właściwościach skryptu.'); const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME); const rows = sheet.getDataRange().getValues(); const pending = []; for (let i = 1; i < rows.length; i++) { const rawPhone = rows[i][PHONE_COL - 1]; const message = String(rows[i][MESSAGE_COL - 1]).trim(); const status = String(rows[i][STATUS_COL - 1]); if (rawPhone === '' || !message || status.startsWith(SENT_PREFIX)) continue; const phone = normalizePhone(rawPhone); if (!phone) { sheet.getRange(i + 1, STATUS_COL).setValue('Błędny numer'); continue; } pending.push({ row: i + 1, phone: phone, message: message }); } let queued = 0; for (let start = 0; start < pending.length; start += BATCH_SIZE) { const batch = pending.slice(start, start + BATCH_SIZE); const response = UrlFetchApp.fetch(API_URL, { method: 'post', contentType: 'application/json', headers: { 'x-api-key': apiKey }, payload: JSON.stringify({ messages: batch.map(item => ({ recipients: [item.phone], message: item.message })), }), muteHttpExceptions: true, }); const code = response.getResponseCode(); const ok = code >= 200 && code < 300; const status = ok ? SENT_PREFIX + ' ' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm') : 'Błąd ' + code + ': ' + response.getContentText().slice(0, 150); batch.forEach(item => sheet.getRange(item.row, STATUS_COL).setValue(status)); if (!ok) break; // 400, 401 i 429 nie znikną przy kolejnej partii queued += batch.length; } SpreadsheetApp.getActive().toast('Do kolejki trafiło SMS-ów: ' + queued); } // 600 100 200 -> +48600100200. Numery z + albo 00 zostają bez zmian. function normalizePhone(value) { const raw = String(value).trim(); let digits = raw.replace(/\D/g, ''); if (!digits) return ''; if (raw.startsWith('+')) { // już w formacie międzynarodowym } else if (digits.startsWith('00')) { digits = digits.slice(2); } else if (!(digits.startsWith(COUNTRY_CODE) && digits.length > 9)) { digits = COUNTRY_CODE + digits.replace(/^0+/, ''); } return digits.length >= 8 && digits.length <= 15 ? '+' + digits : ''; }Uruchom z menu
Odśwież arkusz. Pojawi się menu SMS → Wyślij niewysłane wiersze. Za pierwszym razem Google poprosi o zgodę na dostęp do arkusza i do zewnętrznych adresów. Najpierw wyślij dwa wiersze z własnymi numerami.
Sprawdź statusy
Kolumna D dostaje „W kolejce” z datą. To znaczy, że SMS-y czekają na telefon, który wysyła je co 5 sekund (około 720 na godzinę). Wiersz z błędem dostanie jego opis i wyjdzie przy następnym uruchomieniu.
Jak zbierać odpowiedzi klientów w arkuszu
smsportal wysyła zdarzenie MESSAGE_RECEIVED jako POST z JSON-em, a skrypt wdrożony jako aplikacja internetowa dopisuje wiersz: czas, nadawca, treść. Apps Script nie widzi nagłówków żądania, więc nie sprawdzi X-Signature. Zamiast tego adres zawiera tajny token, a powtórki tego samego zdarzenia odsiewa idempotencyKey.
- Dodaj zakładkę
Odpowiedzi, w tym samym projekcie Apps Script nowy plik z kodem poniżej, a we właściwościach skryptuWEBHOOK_TOKENz losowym ciągiem. - Wdróż → Nowe wdrożenie → Aplikacja internetowa. Wykonaj jako: Ja, kto ma dostęp: Każdy. Skopiuj adres kończący się na
/exec. - W panelu smsportal Webhooki → Utwórz webhook: adres
.../exec?token=TWOJ_TOKEN, sekret podpisu (co najmniej 20 znaków), zdarzenieMESSAGE_RECEIVED. - Apps Script odpowiada na POST przekierowaniem, a smsportal za nim podąża. Odpisz na SMS i sprawdź wynik w Webhooki → Dostarczenia. Jeśli dostawy nie przechodzą, odbieraj odpowiedzi w n8n lub Make.
// Odpowiedzi klientów do zakładki „Odpowiedzi”.
// Właściwość skryptu WEBHOOK_TOKEN: losowy ciąg, ten sam co w adresie webhooka.
const REPLIES_SHEET = 'Odpowiedzi';
function doPost(e) {
const token = PropertiesService.getScriptProperties().getProperty('WEBHOOK_TOKEN');
if (!token || e.parameter.token !== token) {
return ContentService.createTextOutput('forbidden');
}
const data = JSON.parse(e.postData.contents);
if (data.webhookEvent !== 'MESSAGE_RECEIVED') {
return ContentService.createTextOutput('ignored');
}
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const sheet = SpreadsheetApp.getActive().getSheetByName(REPLIES_SHEET);
const keys = sheet.getRange(1, 4, Math.max(sheet.getLastRow(), 1)).getValues().flat();
if (!keys.includes(data.idempotencyKey)) {
sheet.appendRow([data.receivedAt, data.sender, data.message, data.idempotencyKey]);
}
} finally {
lock.releaseLock();
}
return ContentService.createTextOutput('ok');
}Gotowe scenariusze
Przypomnienia o jutrzejszych wizytach
Kolumna E z datą wizyty, F z godziną wpisaną jako tekst, w C formuła
=JEŻELI(E2=DZIŚ()+1; "Przypominamy o wizycie jutro o "&F2; ""). Wyzwalacz czasowy (Wyzwalacze → Dodaj wyzwalacz, codziennie 17-18) uruchamiasendPendingSms, a wiersze z innymi datami mają pustą treść i czekają.Przypomnienia o zaległych płatnościach
Arkusz z kwotą i terminem, treść z formuły: „Przypominamy o fakturze 12/10 na 450 zł, termin minął 20.10”. Status w D pokazuje, komu przypomnienie już poszło, więc drugie uruchomienie nie wyśle go ponownie.
Zmiana dla uczestników wydarzenia
Lista zapisanych z formularza, ta sama treść w każdym wierszu: „Warsztaty przeniesione do sali 2”. 120 osób to trzy żądania po 50 wierszy, a telefon wyśle wszystko w około 10 minut.
Błędy i rozwiązania
- W kolumnie D „Błąd 401”
- Przyczyna: Zły klucz albo literówka w nazwie właściwości
SMSPORTAL_API_KEY. - Co zrobić: Sprawdź właściwość w Ustawieniach projektu. Skrypt wysyła nagłówek
x-api-key, nieAuthorization. - W kolumnie D „Błąd 429”
- Przyczyna: Wyczerpany limit SMS-ów w pakiecie albo partia większa niż pozwala plan.
- Co zrobić: Treść błędu mówi, który limit się skończył. Przy limicie partii zmniejsz
BATCH_SIZE. - „Błędny numer” w wielu wierszach
- Przyczyna: Numer bez kierunkowego innego kraju albo zbyt krótki po usunięciu spacji.
- Co zrobić: Wpisuj numery z
+i kierunkowym. Numery z 9 cyfr skrypt traktuje jako polskie (COUNTRY_CODE). - Odpowiedzi nie trafiają do arkusza po zmianie kodu
- Przyczyna: Adres
/execserwuje wdrożoną wersję, nie bieżący kod. - Co zrobić: Wdróż → Zarządzaj wdrożeniami → edytuj → Nowa wersja. Adres się nie zmienia.
- Status „W kolejce”, ale SMS nie dochodzi
- Przyczyna: Telefon jest offline albo Android usypia aplikację.
- Co zrobić: Sprawdź zasięg, internet i stan telefonu w panelu. Wiadomości czekają do 72 godzin, potem wygasają.
Najczęstsze pytania
Jakie limity ma Apps Script?
Według Google: 20 000 wywołań UrlFetch dziennie na koncie prywatnym i 100 000 w Google Workspace, do 6 minut na jedno uruchomienie, a wyzwalacze łącznie 90 minut dziennie (prywatne) lub 6 godzin (Workspace). Przy 50 wierszach na żądanie realnym limitem jest telefon, nie Google.
Dlaczego jedno żądanie na partię, a nie na wiersz?
`send-bulk-sms` przyjmuje listę wiadomości z różną treścią dla każdej osoby, więc 500 wierszy to 10 żądań zamiast 500. Skrypt kończy się w kilka sekund, a resztę robi kolejka telefonu.
Czy mogę wysłać SMS bez pisania skryptu?
Tak. Plik → Pobierz → Wartości rozdzielane przecinkami (.csv), potem w panelu smsportal Wysyłka zbiorcza: wgrywasz plik, wskazujesz kolumnę z numerem i wstawiasz zmienne typu `{{ imie }}`.
Czy skrypt wyśle SMS automatycznie po dodaniu wiersza?
Dodaj wyzwalacz czasowy, np. co 15 minut, na `sendPendingSms`. Wiersze ze statusem „W kolejce” są pomijane, więc nikt nie dostanie SMS-a dwa razy.
Czy mogę tak wysłać promocję do całej bazy?
Tylko do osób, które dały zgodę marketingową, zgodnie z Prawem komunikacji elektronicznej. Z karty SIM wysyłaj wiadomości, których odbiorca się spodziewa: przypomnienia, statusy, zmiany terminu. Kampanie należą do dostawcy z nazwą nadawcy.
Źródła
- Google Apps Script: limity i kwoty
- Google Apps Script: UrlFetchApp
- Google Apps Script: właściwości skryptu
- Google Apps Script: własne menu
- Google Apps Script: aplikacje internetowe (doPost)
- smsportal: dokumentacja API
Więcej w poradnikach
Wyślij pierwszego SMS-a jeszcze dziś
Załóż darmowe konto, podłącz telefon i sprawdź, jak klienci reagują na wiadomości z Twojego numeru.